merge: all upstream changes
This commit is contained in:
commit
f8f128b347
170 changed files with 4490 additions and 2218 deletions
|
|
@ -228,7 +228,7 @@ export class AccountMoveService {
|
|||
},
|
||||
}).then(memberships => memberships.map(membership => membership.userListId));
|
||||
|
||||
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
|
||||
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; userListUserId: string; }> = new Map();
|
||||
|
||||
// 重複しないようにIDを生成
|
||||
const genId = (): string => {
|
||||
|
|
@ -244,6 +244,7 @@ export class AccountMoveService {
|
|||
createdAt: new Date(),
|
||||
userId: dst.id,
|
||||
userListId: membership.userListId,
|
||||
userListUserId: membership.userListUserId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,9 +158,13 @@ export class AnnouncementService {
|
|||
|
||||
if (moderator) {
|
||||
if (announcement.userId) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId });
|
||||
this.moderationLogService.log(moderator, 'deleteUserAnnouncement', {
|
||||
announcementId: announcement.id,
|
||||
announcement: announcement,
|
||||
userId: announcement.userId,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
} else {
|
||||
this.moderationLogService.log(moderator, 'deleteGlobalAnnouncement', {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -38,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
private utilityService: UtilityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
this.antennasFetched = false;
|
||||
this.antennas = [];
|
||||
|
|
@ -84,12 +86,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
||||
for (const antenna of matchedAntennas) {
|
||||
redisPipeline.xadd(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
|
||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ import { UtilityService } from './UtilityService.js';
|
|||
import { FileInfoService } from './FileInfoService.js';
|
||||
import { SearchService } from './SearchService.js';
|
||||
import { ClipService } from './ClipService.js';
|
||||
import { FeaturedService } from './FeaturedService.js';
|
||||
import { RedisTimelineService } from './RedisTimelineService.js';
|
||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||
import FederationChart from './chart/charts/federation.js';
|
||||
import NotesChart from './chart/charts/notes.js';
|
||||
|
|
@ -189,6 +191,8 @@ const $UtilityService: Provider = { provide: 'UtilityService', useExisting: Util
|
|||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
const $RedisTimelineService: Provider = { provide: 'RedisTimelineService', useExisting: RedisTimelineService };
|
||||
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
|
|
@ -321,6 +325,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
FileInfoService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
RedisTimelineService,
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
|
|
@ -446,6 +452,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$FileInfoService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$RedisTimelineService,
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
|
|
@ -572,6 +580,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
FileInfoService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
RedisTimelineService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
|
|
@ -696,6 +706,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$FileInfoService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$RedisTimelineService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
|
||||
fromRedisConverter: (value) => {
|
||||
if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す)
|
||||
return new Map(JSON.parse(value).map((x: Serialized<MiEmoji>) => [x.name, {
|
||||
...x,
|
||||
updatedAt: x.updatedAt ? new Date(x.updatedAt) : null,
|
||||
|
|
@ -386,6 +385,20 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ローカル内の絵文字に重複がないかチェックします
|
||||
* @param name 絵文字名
|
||||
*/
|
||||
@bindThis
|
||||
public checkDuplicate(name: string): Promise<boolean> {
|
||||
return this.emojisRepository.exist({ where: { name, host: IsNull() } });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getEmojiById(id: string): Promise<MiEmoji | null> {
|
||||
return this.emojisRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.cache.dispose();
|
||||
|
|
|
|||
116
packages/backend/src/core/FeaturedService.ts
Normal file
116
packages/backend/src/core/FeaturedService.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { MiNote, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
||||
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
||||
|
||||
@Injectable()
|
||||
export class FeaturedService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getCurrentWindow(windowRange: number): number {
|
||||
const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime();
|
||||
return Math.floor(passed / windowRange);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const redisTransaction = this.redisClient.multi();
|
||||
redisTransaction.zincrby(
|
||||
`${name}:${currentWindow}`,
|
||||
score,
|
||||
element);
|
||||
redisTransaction.expire(
|
||||
`${name}:${currentWindow}`,
|
||||
(windowRange * 3) / 1000,
|
||||
'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
await redisTransaction.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getRankingOf(name: string, windowRange: number, threshold: number): Promise<string[]> {
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const previousWindow = currentWindow - 1;
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.zrange(
|
||||
`${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
||||
redisPipeline.zrange(
|
||||
`${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
||||
const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => r[1] as string[]) : [[], []]);
|
||||
|
||||
const ranking = new Map<string, number>();
|
||||
for (let i = 0; i < currentRankingResult.length; i += 2) {
|
||||
const noteId = currentRankingResult[i];
|
||||
const score = parseInt(currentRankingResult[i + 1], 10);
|
||||
ranking.set(noteId, score);
|
||||
}
|
||||
for (let i = 0; i < previousRankingResult.length; i += 2) {
|
||||
const noteId = previousRankingResult[i];
|
||||
const score = parseInt(previousRankingResult[i + 1], 10);
|
||||
const exist = ranking.get(noteId);
|
||||
if (exist != null) {
|
||||
ranking.set(noteId, (exist + score) / 2);
|
||||
} else {
|
||||
ranking.set(noteId, score);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ranking.keys());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getGlobalNotesRanking(threshold: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getHashtagsRanking(threshold: number): Promise<string[]> {
|
||||
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
|
|
@ -12,15 +13,22 @@ import type { MiHashtag } from '@/models/Hashtag.js';
|
|||
import type { HashtagsRepository } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
@Inject(DI.hashtagsRepository)
|
||||
private hashtagsRepository: HashtagsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private featuredService: FeaturedService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +54,9 @@ export class HashtagService {
|
|||
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
|
||||
tag = normalizeForSearch(tag);
|
||||
|
||||
// TODO: サンプリング
|
||||
this.updateHashtagsRanking(tag, user.id);
|
||||
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (index == null && !inc) return;
|
||||
|
|
@ -85,7 +96,7 @@ export class HashtagService {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.mentionedUserIds.some(id => id === user.id)) {
|
||||
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
|
||||
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
|
||||
|
|
@ -144,4 +155,94 @@ export class HashtagService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise<void> {
|
||||
const instance = await this.metaService.fetch();
|
||||
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
|
||||
if (hiddenTags.includes(hashtag)) return;
|
||||
|
||||
// YYYYMMDDHHmm (10分間隔)
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
|
||||
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
|
||||
if (exist === 1) return;
|
||||
|
||||
this.featuredService.updateHashtagsRanking(hashtag, 1);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
// チャート用
|
||||
redisPipeline.pfadd(`hashtagUsers:${hashtag}:${window}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`,
|
||||
60 * 60 * 24 * 3, // 3日間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
// ユニークカウント用
|
||||
// TODO: Bloom Filter を使うようにしても良さそう
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}`,
|
||||
60 * 60, // 1時間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getChart(hashtag: string, range: number): Promise<number[]> {
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`);
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
const result = await redisPipeline.exec();
|
||||
|
||||
if (result == null) return [];
|
||||
|
||||
return result.map(x => x[1]) as number[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getCharts(hashtags: string[], range: number): Promise<Record<string, number[]>> {
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
for (const hashtag of hashtags) {
|
||||
redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`);
|
||||
}
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
const result = await redisPipeline.exec();
|
||||
|
||||
if (result == null) return {};
|
||||
|
||||
// key is hashtag
|
||||
const charts = {} as Record<string, number[]>;
|
||||
for (const hashtag of hashtags) {
|
||||
charts[hashtag] = [];
|
||||
}
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
for (let j = 0; j < hashtags.length; j++) {
|
||||
charts[hashtags[j]].push(result[(i * hashtags.length) + j][1] as number);
|
||||
}
|
||||
}
|
||||
|
||||
return charts;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
|||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
|
|
@ -193,6 +195,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
private noteReadService: NoteReadService,
|
||||
private notificationService: NotificationService,
|
||||
private relayService: RelayService,
|
||||
|
|
@ -200,6 +203,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
private hashtagService: HashtagService,
|
||||
private antennaService: AntennaService,
|
||||
private webhookService: WebhookService,
|
||||
private featuredService: FeaturedService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
|
|
@ -252,19 +256,30 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
if (data.renote) {
|
||||
switch (data.renote.visibility) {
|
||||
case 'public':
|
||||
// public noteは無条件にrenote可能
|
||||
break;
|
||||
case 'home':
|
||||
// home noteはhome以下にrenote可能
|
||||
if (data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
break;
|
||||
case 'followers':
|
||||
// 他人のfollowers noteはreject
|
||||
if (data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がpublicではないならhomeにする
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
if (data.renote && data.renote.visibility === 'followers') {
|
||||
data.visibility = 'followers';
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
data.visibility = 'followers';
|
||||
break;
|
||||
case 'specified':
|
||||
// specified / direct noteはreject
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
}
|
||||
|
||||
// 返信対象がpublicではないならhomeにする
|
||||
|
|
@ -334,14 +349,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
if (data.channel) {
|
||||
this.redisForTimelines.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
|
||||
() => { /* aborted, ignore this */ },
|
||||
|
|
@ -481,13 +488,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
this.pushToTl(note, user);
|
||||
} else if (data.visibility === 'followers') {
|
||||
this.pushToTl(note, user);
|
||||
} else if (data.visibility === 'specified') {
|
||||
// TODO
|
||||
}
|
||||
this.pushToTl(note, user);
|
||||
|
||||
this.antennaService.addNoteToAntennas(note, user);
|
||||
|
||||
|
|
@ -510,9 +511,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
|
||||
if (!user.isBot) this.incRenoteCount(data.renote);
|
||||
if (data.renote && data.renote.userId !== user.id && !user.isBot) {
|
||||
this.incRenoteCount(data.renote);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
|
|
@ -712,10 +712,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
score: () => '"score" + 1',
|
||||
})
|
||||
.where('id = :id', { id: renote.id })
|
||||
.execute();
|
||||
|
||||
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote.id, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -803,9 +816,15 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const r = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
|
||||
|
||||
this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followeeId: note.channelId,
|
||||
|
|
@ -814,140 +833,94 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
|
||||
for (const channelFollowing of channelFollowings) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: キャッシュ?
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
});
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'userListUserId', 'withReplies'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const userListMemberships = await this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'withReplies'],
|
||||
});
|
||||
if (note.visibility === 'followers') {
|
||||
// TODO: 重そうだから何とかしたい Set 使う?
|
||||
userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||
}
|
||||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
// 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
|
||||
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${following.followerId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${following.followerId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO
|
||||
//if (note.visibility === 'followers') {
|
||||
// // TODO: 重そうだから何とかしたい Set 使う?
|
||||
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||
//}
|
||||
|
||||
for (const userListMembership of userListMemberships) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
// ダイレクトのとき、そのリストが対象外のユーザーの場合
|
||||
if (
|
||||
note.visibility === 'specified' &&
|
||||
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
|
||||
) continue;
|
||||
|
||||
// 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
|
||||
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) {
|
||||
if (!userListMembership.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`userListTimeline:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userListTimelineWithFiles:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
{ // 自分自身のHTL
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
|
||||
this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
} else {
|
||||
redisPipeline.xadd(
|
||||
`userTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
this.redisTimelineService.push('localTimelineWithReplies', note.id, 300, r);
|
||||
}
|
||||
} else {
|
||||
this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
this.redisTimelineService.push('localTimeline', note.id, 1000, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
redisPipeline.xadd(
|
||||
'localTimeline',
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
'localTimelineWithFiles',
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -959,7 +932,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
r.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -64,12 +64,6 @@ export class NoteDeleteService {
|
|||
const deletedAt = new Date();
|
||||
const cascadingNotes = await this.findCascadingNotes(note);
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
|
||||
this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1);
|
||||
if (!user.isBot) this.notesRepository.decrement({ id: note.renoteId }, 'score', 1);
|
||||
}
|
||||
|
||||
if (note.replyId) {
|
||||
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { DataSource, In, LessThan } from 'typeorm';
|
||||
import { DataSource, In, IsNull, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
|
|
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
|||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiApp } from '@/models/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
|
|
@ -48,8 +48,11 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
|||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
|
||||
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
import NotesChart from './chart/charts/notes.js';
|
||||
import PerUserNotesChart from './chart/charts/per-user-notes.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
|
|
@ -151,12 +154,12 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -172,15 +175,21 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.noteEditRepository)
|
||||
private noteEditRepository: NoteEditRepository,
|
||||
|
||||
|
|
@ -189,18 +198,23 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
private noteReadService: NoteReadService,
|
||||
private notificationService: NotificationService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private hashtagService: HashtagService,
|
||||
private antennaService: AntennaService,
|
||||
private webhookService: WebhookService,
|
||||
private featuredService: FeaturedService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
private roleService: RoleService,
|
||||
private metaService: MetaService,
|
||||
private searchService: SearchService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) { }
|
||||
|
|
@ -261,19 +275,30 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
if (data.renote) {
|
||||
switch (data.renote.visibility) {
|
||||
case 'public':
|
||||
// public noteは無条件にrenote可能
|
||||
break;
|
||||
case 'home':
|
||||
// home noteはhome以下にrenote可能
|
||||
if (data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
break;
|
||||
case 'followers':
|
||||
// 他人のfollowers noteはreject
|
||||
if (data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がpublicではないならhomeにする
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
if (data.renote && data.renote.visibility === 'followers') {
|
||||
data.visibility = 'followers';
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
data.visibility = 'followers';
|
||||
break;
|
||||
case 'specified':
|
||||
// specified / direct noteはreject
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
}
|
||||
|
||||
// 返信対象がpublicではないならhomeにする
|
||||
|
|
@ -448,14 +473,6 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
await this.notesRepository.update(oldnote.id, note);
|
||||
}
|
||||
|
||||
if (data.channel) {
|
||||
this.redisClient.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!),
|
||||
() => { /* aborted, ignore this */ },
|
||||
|
|
@ -483,30 +500,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
this.hashtagService.updateHashtags(user, tags);
|
||||
}
|
||||
|
||||
// Word mute
|
||||
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
|
||||
where: {
|
||||
enableWordMute: true,
|
||||
},
|
||||
select: ['userId', 'mutedWords'],
|
||||
})).then(us => {
|
||||
for (const u of us) {
|
||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||
if (shouldMute) {
|
||||
this.mutedNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
userId: u.userId,
|
||||
noteId: note.id,
|
||||
reason: 'word',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
this.pushToTl(note, user);
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
|
|
@ -779,6 +773,165 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
return mentionedUsers;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const r = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
|
||||
|
||||
this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followeeId: note.channelId,
|
||||
},
|
||||
select: ['followerId'],
|
||||
});
|
||||
|
||||
for (const channelFollowing of channelFollowings) {
|
||||
this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: キャッシュ?
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'userListUserId', 'withReplies'],
|
||||
}),
|
||||
]);
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
// TODO: 重そうだから何とかしたい Set 使う?
|
||||
userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||
}
|
||||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
// 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
|
||||
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
for (const userListMembership of userListMemberships) {
|
||||
// ダイレクトのとき、そのリストが対象外のユーザーの場合
|
||||
if (
|
||||
note.visibility === 'specified' &&
|
||||
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
|
||||
) continue;
|
||||
|
||||
// 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
|
||||
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) {
|
||||
if (!userListMembership.withReplies) continue;
|
||||
}
|
||||
|
||||
this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
|
||||
this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
this.redisTimelineService.push('localTimelineWithReplies', note.id, 300, r);
|
||||
}
|
||||
} else {
|
||||
this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
this.redisTimelineService.push('localTimeline', note.id, 1000, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.random() < 0.1) {
|
||||
process.nextTick(() => {
|
||||
this.checkHibernation(followings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
r.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async checkHibernation(followings: MiFollowing[]) {
|
||||
if (followings.length === 0) return;
|
||||
|
||||
const shuffle = (array: MiFollowing[]) => {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
};
|
||||
|
||||
// ランダムに最大1000件サンプリング
|
||||
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
|
||||
|
||||
const hibernatedUsers = await this.usersRepository.find({
|
||||
where: {
|
||||
id: In(samples.map(x => x.followerId)),
|
||||
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ export class PollService {
|
|||
const note = await this.notesRepository.findOneBy({ id: noteId });
|
||||
if (note == null) throw new Error('note not found');
|
||||
|
||||
if (note.localOnly) return;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
if (user == null) throw new Error('note not found');
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -34,6 +35,8 @@ export class QueryService {
|
|||
|
||||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -49,15 +52,15 @@ export class QueryService {
|
|||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceDate && untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'ASC');
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
} else if (untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else {
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
}
|
||||
|
|
@ -76,13 +79,15 @@ export class QueryService {
|
|||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
|
|
@ -112,16 +117,17 @@ export class QueryService {
|
|||
.where('threadMuted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: MiUser): void {
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
|
@ -139,26 +145,31 @@ export class QueryService {
|
|||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
|
|
@ -180,36 +191,41 @@ export class QueryService {
|
|||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(new Brackets(qb => { qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
|
||||
q.setParameters({ meId: me.id });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
|
|
@ -26,6 +27,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
const FALLBACK = '❤';
|
||||
|
||||
|
|
@ -66,6 +68,9 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
|
|||
@Injectable()
|
||||
export class ReactionService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -86,6 +91,7 @@ export class ReactionService {
|
|||
private noteEntityService: NoteEntityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private idService: IdService,
|
||||
private featuredService: FeaturedService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
|
|
@ -182,11 +188,28 @@ export class ReactionService {
|
|||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
... (!user.isBot ? { score: () => '"score" + 1' } : {}),
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (
|
||||
Math.random() < 0.3 &&
|
||||
note.userId !== user.id &&
|
||||
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
|
||||
) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note.id, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
|
|
@ -275,8 +298,6 @@ export class ReactionService {
|
|||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1);
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
|
|
|
|||
80
packages/backend/src/core/RedisTimelineService.ts
Normal file
80
packages/backend/src/core/RedisTimelineService.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
@Injectable()
|
||||
export class RedisTimelineService {
|
||||
constructor(
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) {
|
||||
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、
|
||||
// 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する
|
||||
if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) {
|
||||
pipeline.lpush('list:' + tl, id);
|
||||
if (Math.random() < 0.1) { // 10%の確率でトリム
|
||||
pipeline.ltrim('list:' + tl, 0, maxlen - 1);
|
||||
}
|
||||
} else {
|
||||
// 末尾のIDを取得
|
||||
this.redisForTimelines.lindex('list:' + tl, -1).then(lastId => {
|
||||
if (lastId == null || (this.idService.parse(id).date.getTime() > this.idService.parse(lastId).date.getTime())) {
|
||||
this.redisForTimelines.lpush('list:' + tl, id);
|
||||
} else {
|
||||
Promise.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public get(name: string, untilId?: string | null, sinceId?: string | null) {
|
||||
if (untilId && sinceId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1));
|
||||
} else if (untilId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1));
|
||||
} else if (sinceId) {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1));
|
||||
} else {
|
||||
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||
.then(ids => ids.sort((a, b) => a > b ? -1 : 1));
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getMulti(name: string[], untilId?: string | null, sinceId?: string | null): Promise<string[][]> {
|
||||
const pipeline = this.redisForTimelines.pipeline();
|
||||
for (const n of name) {
|
||||
pipeline.lrange('list:' + n, 0, -1);
|
||||
}
|
||||
return pipeline.exec().then(res => {
|
||||
if (res == null) return [];
|
||||
const tls = res.map(r => r[1] as string[]);
|
||||
return tls.map(ids =>
|
||||
(untilId && sinceId)
|
||||
? ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1)
|
||||
: untilId
|
||||
? ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1)
|
||||
: sinceId
|
||||
? ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1)
|
||||
: ids.sort((a, b) => a > b ? -1 : 1),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export type RolePolicies = {
|
||||
|
|
@ -102,6 +103,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
private globalEventService: GlobalEventService,
|
||||
private idService: IdService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private redisTimelineService: RedisTimelineService,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
|
|
@ -472,12 +474,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (const role of roles) {
|
||||
redisPipeline.xadd(
|
||||
`roleTimeline:${role.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.redisTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
|
||||
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export class UserListService implements OnApplicationShutdown {
|
|||
createdAt: new Date(),
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
userListUserId: list.userId,
|
||||
} as MiUserListMembership);
|
||||
|
||||
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js';
|
|||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||
import type { ReactionService } from '../ReactionService.js';
|
||||
|
|
@ -30,6 +31,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
private driveFileEntityService: DriveFileEntityService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private reactionService: ReactionService;
|
||||
private noteLoader = new DebounceLoader(this.findNoteOrFail);
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
|
@ -289,8 +291,8 @@ export class NoteEntityService implements OnModuleInit {
|
|||
}, options);
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] });
|
||||
const host = note.userHost == null ? this.config.host : note.userHost;
|
||||
const note = typeof src === 'object' ? src : await this.noteLoader.load(src);
|
||||
const host = note.userHost;
|
||||
|
||||
let text = note.text;
|
||||
|
||||
|
|
@ -455,17 +457,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> {
|
||||
// 指定したユーザーの指定したノートのリノートがいくつあるか数える
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.userId = :userId', { userId })
|
||||
.andWhere('note.renoteId = :renoteId', { renoteId });
|
||||
|
||||
// 指定した投稿を除く
|
||||
if (excludeNoteId) {
|
||||
query.andWhere('note.id != :excludeNoteId', { excludeNoteId });
|
||||
}
|
||||
|
||||
return await query.getCount();
|
||||
private findNoteOrFail(id: string): Promise<MiNote> {
|
||||
return this.notesRepository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ export class RoleEntityService {
|
|||
|
||||
const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
|
||||
.where('assign.roleId = :roleId', { roleId: role.id })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('assign.expiresAt IS NULL')
|
||||
.orWhere('assign.expiresAt > :now', { now: new Date() });
|
||||
}))
|
||||
.getCount();
|
||||
|
||||
|
|
|
|||
|
|
@ -147,64 +147,76 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async getRelation(me: MiUser['id'], target: MiUser['id']) {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
});
|
||||
return awaitAll({
|
||||
id: target,
|
||||
const [
|
||||
following,
|
||||
isFollowing: following != null,
|
||||
isFollowed: this.followingsRepository.count({
|
||||
isFollowed,
|
||||
hasPendingFollowRequestFromYou,
|
||||
hasPendingFollowRequestToYou,
|
||||
isBlocking,
|
||||
isBlocked,
|
||||
isMuted,
|
||||
isRenoteMuted,
|
||||
] = await Promise.all([
|
||||
this.followingsRepository.findOneBy({
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
}),
|
||||
this.followingsRepository.exist({
|
||||
where: {
|
||||
followerId: target,
|
||||
followeeId: me,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
hasPendingFollowRequestFromYou: this.followRequestsRepository.count({
|
||||
}),
|
||||
this.followRequestsRepository.exist({
|
||||
where: {
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
hasPendingFollowRequestToYou: this.followRequestsRepository.count({
|
||||
}),
|
||||
this.followRequestsRepository.exist({
|
||||
where: {
|
||||
followerId: target,
|
||||
followeeId: me,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isBlocking: this.blockingsRepository.count({
|
||||
}),
|
||||
this.blockingsRepository.exist({
|
||||
where: {
|
||||
blockerId: me,
|
||||
blockeeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isBlocked: this.blockingsRepository.count({
|
||||
}),
|
||||
this.blockingsRepository.exist({
|
||||
where: {
|
||||
blockerId: target,
|
||||
blockeeId: me,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isMuted: this.mutingsRepository.count({
|
||||
}),
|
||||
this.mutingsRepository.exist({
|
||||
where: {
|
||||
muterId: me,
|
||||
muteeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isRenoteMuted: this.renoteMutingsRepository.count({
|
||||
}),
|
||||
this.renoteMutingsRepository.exist({
|
||||
where: {
|
||||
muterId: me,
|
||||
muteeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: target,
|
||||
following,
|
||||
isFollowing: following != null,
|
||||
isFollowed,
|
||||
hasPendingFollowRequestFromYou,
|
||||
hasPendingFollowRequestToYou,
|
||||
isBlocking,
|
||||
isBlocked,
|
||||
isMuted,
|
||||
isRenoteMuted,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue