Compare commits

...

16 Commits

Author SHA1 Message Date
syuilo
adf9d9c969 2023.10.0-beta.4 2023-10-06 18:01:19 +09:00
syuilo
8c663f65a8 clean up 2023-10-06 17:40:46 +09:00
syuilo
481ca4ec03 add more test 2023-10-06 17:36:54 +09:00
syuilo
e6ca53c5e1 update rollup to 4.0.0 2023-10-06 17:36:43 +09:00
syuilo
95dc70021f update deps 2023-10-06 17:19:17 +09:00
syuilo
fd3295eba4 Update CHANGELOG.md 2023-10-06 17:13:13 +09:00
syuilo
a76cebd897 Update CHANGELOG.md 2023-10-06 17:11:59 +09:00
syuilo
7d289c1b77 refactor 2023-10-06 17:01:06 +09:00
syuilo
0bdbdba9f8 refactor 2023-10-06 16:58:38 +09:00
syuilo
4489ca3c74 refactor 2023-10-06 16:28:21 +09:00
syuilo
87416710c3 enhance(backend): some tweaks 2023-10-06 16:17:29 +09:00
syuilo
132b01461d refactor 2023-10-06 16:10:59 +09:00
syuilo
dab205edb8 enhance(backend): improve featured system 2023-10-06 14:24:25 +09:00
syuilo
e4dcab8671 chore(backend): response isHibernated in admin/show-user 2023-10-05 21:35:23 +09:00
syuilo
780721e9a2 clean up 2023-10-05 20:34:50 +09:00
anatawa12
ee483f2dee Disallow renote of direct note (#11970)
* chore: renoteに関するチェックをまとめる

* fix: ダイレクト投稿をrenoteできる

* fix(frontend): 自分のダイレクト投稿をrenoteできる

* docs(changelog): ダイレクト投稿をリノートできてしまう

* fix lint

* chore(backend): visibilityに関するエラーをApi Errorとして返す
2023-10-05 17:03:50 +09:00
23 changed files with 1119 additions and 933 deletions

View File

@@ -14,8 +14,9 @@
## 2023.10.0 ## 2023.10.0
### NOTE ### NOTE
- muted_noteテーブルは使われなくなったため手動で削除を行ってください。
- 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました - 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました
- アップデート後、アップデートより前の時点にTLを遡ることはできません
- アップデート後であっても、今後のアップデートで2023.10.0以前のTLに遡れるようになる可能性はあります
### Changes ### Changes
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました - API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
@@ -27,13 +28,18 @@
- Enhance: ソフトワードミュートとハードワードミュートは統合されました - Enhance: ソフトワードミュートとハードワードミュートは統合されました
- Enhance: モデレーションログ機能の強化 - Enhance: モデレーションログ機能の強化
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新
- Enhance: 依存関係の更新
- Fix: ダイレクト投稿をリノートできてしまう問題を修正
- Fix: ユーザーリストTLにチャンネル投稿が含まれる問題を修正
### Client ### Client
- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に - Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
### Server ### Server
- Enhance: タイムライン取得時のパフォーマンスを改善 - Enhance: タイムライン取得時のパフォーマンスを大幅に向上
- Enhance: ハイライト取得時のパフォーマンスを大幅に向上
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
## 2023.9.3 ## 2023.9.3
### General ### General

View File

@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.10.0-beta.3", "version": "2023.10.0-beta.4",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -47,12 +47,12 @@
"cssnano": "6.0.1", "cssnano": "6.0.1",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"postcss": "8.4.31", "postcss": "8.4.31",
"terser": "5.20.0", "terser": "5.21.0",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "6.7.3", "@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.3", "@typescript-eslint/parser": "6.7.4",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.3.0", "cypress": "13.3.0",
"eslint": "8.50.0", "eslint": "8.50.0",

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CleanUp1696569742153 {
name = 'CleanUp1696569742153'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_01f4581f114e0ebd2bbb876f0b"`);
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "score"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD "score" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt") `);
}
}

View File

@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CleanUp1696581429196 {
name = 'CleanUp1696581429196'
async up(queryRunner) {
await queryRunner.query(`DROP TABLE IF EXISTS "muted_note"`);
}
async down(queryRunner) {
}
}

View File

@@ -71,14 +71,14 @@
"@fastify/multipart": "8.0.0", "@fastify/multipart": "8.0.0",
"@fastify/static": "6.11.2", "@fastify/static": "6.11.2",
"@fastify/view": "8.2.0", "@fastify/view": "8.2.0",
"@nestjs/common": "10.2.6", "@nestjs/common": "10.2.7",
"@nestjs/core": "10.2.6", "@nestjs/core": "10.2.7",
"@nestjs/testing": "10.2.6", "@nestjs/testing": "10.2.7",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.2.0", "@simplewebauthn/server": "8.2.0",
"@sinonjs/fake-timers": "11.1.0", "@sinonjs/fake-timers": "11.1.0",
"@swc/cli": "0.1.62", "@swc/cli": "0.1.62",
"@swc/core": "1.3.90", "@swc/core": "1.3.92",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "6.0.1", "archiver": "6.0.1",
@@ -86,7 +86,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "4.11.4", "bullmq": "4.12.2",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.1", "cbor": "9.0.1",
"chalk": "5.3.0", "chalk": "5.3.0",
@@ -155,7 +155,7 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.9", "systeminformation": "5.21.11",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
@@ -189,7 +189,7 @@
"@types/jsrsasign": "10.5.9", "@types/jsrsasign": "10.5.9",
"@types/mime-types": "2.1.2", "@types/mime-types": "2.1.2",
"@types/ms": "0.7.32", "@types/ms": "0.7.32",
"@types/node": "20.7.1", "@types/node": "20.8.2",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.11", "@types/nodemailer": "6.4.11",
"@types/oauth": "0.9.2", "@types/oauth": "0.9.2",
@@ -212,8 +212,8 @@
"@types/vary": "1.1.1", "@types/vary": "1.1.1",
"@types/web-push": "3.6.1", "@types/web-push": "3.6.1",
"@types/ws": "8.5.6", "@types/ws": "8.5.6",
"@typescript-eslint/eslint-plugin": "6.7.3", "@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.3", "@typescript-eslint/parser": "6.7.4",
"aws-sdk-client-mock": "3.0.0", "aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.50.0", "eslint": "8.50.0",

View File

@@ -60,6 +60,7 @@ import { UtilityService } from './UtilityService.js';
import { FileInfoService } from './FileInfoService.js'; import { FileInfoService } from './FileInfoService.js';
import { SearchService } from './SearchService.js'; import { SearchService } from './SearchService.js';
import { ClipService } from './ClipService.js'; import { ClipService } from './ClipService.js';
import { FeaturedService } from './FeaturedService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js'; import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js'; import NotesChart from './chart/charts/notes.js';
@@ -187,6 +188,7 @@ const $UtilityService: Provider = { provide: 'UtilityService', useExisting: Util
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -318,6 +320,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FileInfoService, FileInfoService,
SearchService, SearchService,
ClipService, ClipService,
FeaturedService,
ChartLoggerService, ChartLoggerService,
FederationChart, FederationChart,
NotesChart, NotesChart,
@@ -442,6 +445,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FileInfoService, $FileInfoService,
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService,
$ChartLoggerService, $ChartLoggerService,
$FederationChart, $FederationChart,
$NotesChart, $NotesChart,
@@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FileInfoService, FileInfoService,
SearchService, SearchService,
ClipService, ClipService,
FeaturedService,
FederationChart, FederationChart,
NotesChart, NotesChart,
UsersChart, UsersChart,
@@ -690,6 +695,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FileInfoService, $FileInfoService,
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService,
$FederationChart, $FederationChart,
$NotesChart, $NotesChart,
$UsersChart, $UsersChart,

View File

@@ -0,0 +1,94 @@
/*
* 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 } 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日ごと
@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, limit: number): Promise<string[]> {
const currentWindow = this.getCurrentWindow(windowRange);
const previousWindow = currentWindow - 1;
const [currentRankingResult, previousRankingResult] = await Promise.all([
this.redisClient.zrange(
`${name}:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'),
this.redisClient.zrange(
`${name}:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'),
]);
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(noteId: MiNote['id'], channelId: MiNote['channelId'], score = 1): Promise<void> {
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
}
@bindThis
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
}
@bindThis
public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit);
}
}

View File

@@ -53,6 +53,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -200,6 +201,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private hashtagService: HashtagService, private hashtagService: HashtagService,
private antennaService: AntennaService, private antennaService: AntennaService,
private webhookService: WebhookService, private webhookService: WebhookService,
private featuredService: FeaturedService,
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
@@ -252,19 +254,30 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject if (data.renote) {
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { switch (data.renote.visibility) {
throw new Error('Renote target is not public or home'); 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にする // Renote対象がfollowersならfollowersにする
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { data.visibility = 'followers';
data.visibility = 'home'; break;
} case 'specified':
// specified / direct noteはreject
// Renote対象がfollowersならfollowersにする throw new Error('Renote target is not public or home');
if (data.renote && data.renote.visibility === 'followers') { }
data.visibility = 'followers';
} }
// 返信対象がpublicではないならhomeにする // 返信対象がpublicではないならhomeにする
@@ -508,9 +521,8 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
} }
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき if (data.renote && data.renote.userId !== user.id && !user.isBot) {
if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) { this.incRenoteCount(data.renote);
if (!user.isBot) this.incRenoteCount(data.renote);
} }
if (data.poll && data.poll.expiresAt) { if (data.poll && data.poll.expiresAt) {
@@ -710,10 +722,18 @@ export class NoteCreateService implements OnApplicationShutdown {
this.notesRepository.createQueryBuilder().update() this.notesRepository.createQueryBuilder().update()
.set({ .set({
renoteCount: () => '"renoteCount" + 1', renoteCount: () => '"renoteCount" + 1',
score: () => '"score" + 1',
}) })
.where('id = :id', { id: renote.id }) .where('id = :id', { id: renote.id })
.execute(); .execute();
// 30%の確率でハイライト用ランキング更新
if (Math.random() < 0.3) {
if (renote.channelId != null) {
this.featuredService.updateInChannelNotesRanking(renote.id, renote.channelId, 1);
} else if (renote.visibility === 'public' && renote.userHost == null) {
this.featuredService.updateGlobalNotesRanking(renote.id, 1);
}
}
} }
@bindThis @bindThis

View File

@@ -64,12 +64,6 @@ export class NoteDeleteService {
const deletedAt = new Date(); const deletedAt = new Date();
const cascadingNotes = await this.findCascadingNotes(note); 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) { if (note.replyId) {
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
} }

View File

@@ -4,6 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.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 { UserBlockingService } from '@/core/UserBlockingService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
const FALLBACK = '❤'; const FALLBACK = '❤';
@@ -66,6 +68,9 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
@Injectable() @Injectable()
export class ReactionService { export class ReactionService {
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@@ -86,6 +91,7 @@ export class ReactionService {
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
private idService: IdService, private idService: IdService,
private featuredService: FeaturedService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
@@ -182,11 +188,19 @@ export class ReactionService {
await this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
reactions: () => sql, reactions: () => sql,
... (!user.isBot ? { score: () => '"score" + 1' } : {}),
}) })
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();
// 30%の確率でハイライト用ランキング更新
if (Math.random() < 0.3 && note.userId !== user.id) {
if (note.channelId != null) {
this.featuredService.updateInChannelNotesRanking(note.id, note.channelId, 1);
} else if (note.visibility === 'public' && note.userHost == null) {
this.featuredService.updateGlobalNotesRanking(note.id, 1);
}
}
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (meta.enableChartsForRemoteUser || (user.host == null)) { if (meta.enableChartsForRemoteUser || (user.host == null)) {
@@ -275,8 +289,6 @@ export class ReactionService {
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();
if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1);
this.globalEventService.publishNoteStream(note.id, 'unreacted', { this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction, reaction: this.decodeReaction(exist.reaction).reaction,
userId: user.id, userId: user.id,

View File

@@ -450,19 +450,4 @@ export class NoteEntityService implements OnModuleInit {
} }
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
} }
@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();
}
} }

View File

@@ -146,64 +146,76 @@ export class UserEntityService implements OnModuleInit {
@bindThis @bindThis
public async getRelation(me: MiUser['id'], target: MiUser['id']) { public async getRelation(me: MiUser['id'], target: MiUser['id']) {
const following = await this.followingsRepository.findOneBy({ const [
followerId: me,
followeeId: target,
});
return awaitAll({
id: target,
following, following,
isFollowing: following != null, isFollowed,
isFollowed: this.followingsRepository.count({ hasPendingFollowRequestFromYou,
hasPendingFollowRequestToYou,
isBlocking,
isBlocked,
isMuted,
isRenoteMuted,
] = await Promise.all([
this.followingsRepository.findOneBy({
followerId: me,
followeeId: target,
}),
this.followingsRepository.exist({
where: { where: {
followerId: target, followerId: target,
followeeId: me, followeeId: me,
}, },
take: 1, }),
}).then(n => n > 0), this.followRequestsRepository.exist({
hasPendingFollowRequestFromYou: this.followRequestsRepository.count({
where: { where: {
followerId: me, followerId: me,
followeeId: target, followeeId: target,
}, },
take: 1, }),
}).then(n => n > 0), this.followRequestsRepository.exist({
hasPendingFollowRequestToYou: this.followRequestsRepository.count({
where: { where: {
followerId: target, followerId: target,
followeeId: me, followeeId: me,
}, },
take: 1, }),
}).then(n => n > 0), this.blockingsRepository.exist({
isBlocking: this.blockingsRepository.count({
where: { where: {
blockerId: me, blockerId: me,
blockeeId: target, blockeeId: target,
}, },
take: 1, }),
}).then(n => n > 0), this.blockingsRepository.exist({
isBlocked: this.blockingsRepository.count({
where: { where: {
blockerId: target, blockerId: target,
blockeeId: me, blockeeId: me,
}, },
take: 1, }),
}).then(n => n > 0), this.mutingsRepository.exist({
isMuted: this.mutingsRepository.count({
where: { where: {
muterId: me, muterId: me,
muteeId: target, muteeId: target,
}, },
take: 1, }),
}).then(n => n > 0), this.renoteMutingsRepository.exist({
isRenoteMuted: this.renoteMutingsRepository.count({
where: { where: {
muterId: me, muterId: me,
muteeId: target, muteeId: target,
}, },
take: 1, }),
}).then(n => n > 0), ]);
});
return {
id: target,
following,
isFollowing: following != null,
isFollowed,
hasPendingFollowRequestFromYou,
hasPendingFollowRequestToYou,
isBlocking,
isBlocked,
isMuted,
isRenoteMuted,
};
} }
@bindThis @bindThis
@@ -290,24 +302,6 @@ export class UserEntityService implements OnModuleInit {
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
// migration
if (user.avatarId != null && user.avatarUrl === null) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
this.usersRepository.update(user.id, {
avatarUrl: user.avatarUrl,
avatarBlurhash: avatar.blurhash,
});
}
if (user.bannerId != null && user.bannerUrl === null) {
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
this.usersRepository.update(user.id, {
bannerUrl: user.bannerUrl,
bannerBlurhash: banner.blurhash,
});
}
const meId = me ? me.id : null; const meId = me ? me.id : null;
const isMe = meId === user.id; const isMe = meId === user.id;
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;

View File

@@ -138,11 +138,6 @@ export class MiNote {
}) })
public url: string | null; public url: string | null;
@Column('integer', {
default: 0, select: false,
})
public score: number;
@Index() @Index()
@Column({ @Column({
...id(), ...id(),

View File

@@ -14,7 +14,6 @@ export class MiNoteReaction {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@Index()
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
comment: 'The created date of the NoteReaction.', comment: 'The created date of the NoteReaction.',
}) })

View File

@@ -85,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isModerator: isModerator, isModerator: isModerator,
isSilenced: isSilenced, isSilenced: isSilenced,
isSuspended: user.isSuspended, isSuspended: user.isSuspended,
isHibernated: user.isHibernated,
lastActiveDate: user.lastActiveDate, lastActiveDate: user.lastActiveDate,
moderationNote: profile.moderationNote ?? '', moderationNote: profile.moderationNote ?? '',
signins, signins,

View File

@@ -57,6 +57,12 @@ export const meta = {
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
}, },
cannotRenoteDueToVisibility: {
message: 'You can not Renote due to target visibility.',
code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
},
noSuchReplyTarget: { noSuchReplyTarget: {
message: 'No such reply target.', message: 'No such reply target.',
code: 'NO_SUCH_REPLY_TARGET', code: 'NO_SUCH_REPLY_TARGET',
@@ -231,6 +237,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.youHaveBeenBlocked); throw new ApiError(meta.errors.youHaveBeenBlocked);
} }
} }
if (renote.visibility === 'followers' && renote.userId !== me.id) {
// 他人のfollowers noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
}
} }
let reply: MiNote | null = null; let reply: MiNote | null = null;

View File

@@ -6,9 +6,9 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@@ -40,41 +40,50 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private globalNotesRankingCache: string[] = [];
private globalNotesRankingCacheLastFetchedAt = 0;
constructor( constructor(
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private featuredService: FeaturedService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで let noteIds: string[];
if (ps.channelId) {
noteIds = await this.featuredService.getInChannelNotesRanking(ps.channelId, 50);
} else {
if (this.globalNotesRankingCacheLastFetchedAt !== 0 && (Date.now() - this.globalNotesRankingCacheLastFetchedAt < 1000 * 60 * 30)) {
noteIds = this.globalNotesRankingCache;
} else {
noteIds = await this.featuredService.getGlobalNotesRanking(100);
this.globalNotesRankingCache = noteIds;
this.globalNotesRankingCacheLastFetchedAt = Date.now();
}
}
if (noteIds.length === 0) {
return [];
}
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds.slice(ps.offset, ps.offset + ps.limit);
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.addSelect('note.score') .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.where('note.userHost IS NULL')
.andWhere('note.score > 0')
.andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) })
.andWhere('note.visibility = \'public\'')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);
if (me) this.queryService.generateMutedUserQuery(query, me); // TODO: ミュート等考慮
if (me) this.queryService.generateBlockedUserQuery(query, me);
let notes = await query
.orderBy('note.score', 'DESC')
.limit(100)
.getMany();
notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
notes = notes.slice(ps.offset, ps.offset + ps.limit);
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });

View File

@@ -746,6 +746,22 @@ describe('Timelines', () => {
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
}); });
test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', channelId: channel.id });
await waitForPushToTl();
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);

View File

@@ -18,13 +18,13 @@
"dependencies": { "dependencies": {
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@github/webauthn-json": "2.1.1", "@github/webauthn-json": "2.1.1",
"@rollup/plugin-alias": "5.0.0", "@rollup/plugin-alias": "5.0.1",
"@rollup/plugin-json": "6.0.0", "@rollup/plugin-json": "6.0.1",
"@rollup/plugin-replace": "5.0.2", "@rollup/plugin-replace": "5.0.3",
"@rollup/pluginutils": "5.0.4", "@rollup/pluginutils": "5.0.5",
"@syuilo/aiscript": "0.16.0", "@syuilo/aiscript": "0.16.0",
"@tabler/icons-webfont": "2.37.0", "@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.3.4", "@vitejs/plugin-vue": "4.4.0",
"@vue-macros/reactivity-transform": "0.3.23", "@vue-macros/reactivity-transform": "0.3.23",
"@vue/compiler-sfc": "3.3.4", "@vue/compiler-sfc": "3.3.4",
"astring": "1.8.6", "astring": "1.8.6",
@@ -38,7 +38,7 @@
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "7.2.0", "chromatic": "7.2.2",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
@@ -53,13 +53,13 @@
"matter-js": "0.19.0", "matter-js": "0.19.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"photoswipe": "5.4.1", "photoswipe": "5.4.2",
"prismjs": "1.29.0", "prismjs": "1.29.0",
"punycode": "2.3.0", "punycode": "2.3.0",
"querystring": "0.2.1", "querystring": "0.2.1",
"rollup": "3.29.4", "rollup": "4.0.0",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"sass": "1.68.0", "sass": "1.69.0",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.157.0", "three": "0.157.0",
@@ -72,36 +72,36 @@
"uuid": "9.0.1", "uuid": "9.0.1",
"v-code-diff": "1.7.1", "v-code-diff": "1.7.1",
"vanilla-tilt": "1.8.1", "vanilla-tilt": "1.8.1",
"vite": "4.4.9", "vite": "4.4.11",
"vue": "3.3.4", "vue": "3.3.4",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "7.4.5", "@storybook/addon-actions": "7.4.6",
"@storybook/addon-essentials": "7.4.5", "@storybook/addon-essentials": "7.4.6",
"@storybook/addon-interactions": "7.4.5", "@storybook/addon-interactions": "7.4.6",
"@storybook/addon-links": "7.4.5", "@storybook/addon-links": "7.4.6",
"@storybook/addon-storysource": "7.4.5", "@storybook/addon-storysource": "7.4.6",
"@storybook/addons": "7.4.5", "@storybook/addons": "7.4.6",
"@storybook/blocks": "7.4.5", "@storybook/blocks": "7.4.6",
"@storybook/core-events": "7.4.5", "@storybook/core-events": "7.4.6",
"@storybook/jest": "0.2.2", "@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.4.5", "@storybook/manager-api": "7.4.6",
"@storybook/preview-api": "7.4.5", "@storybook/preview-api": "7.4.6",
"@storybook/react": "7.4.5", "@storybook/react": "7.4.6",
"@storybook/react-vite": "7.4.5", "@storybook/react-vite": "7.4.6",
"@storybook/testing-library": "0.2.1", "@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.4.5", "@storybook/theming": "7.4.6",
"@storybook/types": "7.4.5", "@storybook/types": "7.4.6",
"@storybook/vue3": "7.4.5", "@storybook/vue3": "7.4.6",
"@storybook/vue3-vite": "7.4.5", "@storybook/vue3-vite": "7.4.6",
"@testing-library/vue": "7.0.0", "@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
"@types/estree": "1.0.2", "@types/estree": "1.0.2",
"@types/matter-js": "0.19.1", "@types/matter-js": "0.19.1",
"@types/micromatch": "4.0.3", "@types/micromatch": "4.0.3",
"@types/node": "20.7.1", "@types/node": "20.8.2",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.1", "@types/sanitize-html": "2.9.1",
"@types/throttle-debounce": "5.0.0", "@types/throttle-debounce": "5.0.0",
@@ -109,9 +109,9 @@
"@types/uuid": "9.0.4", "@types/uuid": "9.0.4",
"@types/websocket": "1.0.7", "@types/websocket": "1.0.7",
"@types/ws": "8.5.6", "@types/ws": "8.5.6",
"@typescript-eslint/eslint-plugin": "6.7.3", "@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.3", "@typescript-eslint/parser": "6.7.4",
"@vitest/coverage-v8": "0.34.5", "@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.4", "@vue/runtime-core": "3.3.4",
"acorn": "8.10.0", "acorn": "8.10.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
@@ -122,18 +122,18 @@
"fast-glob": "3.3.1", "fast-glob": "3.3.1",
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"micromatch": "4.0.5", "micromatch": "4.0.5",
"msw": "1.3.1", "msw": "1.3.2",
"msw-storybook-addon": "1.8.0", "msw-storybook-addon": "1.8.0",
"nodemon": "3.0.1", "nodemon": "3.0.1",
"prettier": "3.0.3", "prettier": "3.0.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"start-server-and-test": "2.0.1", "start-server-and-test": "2.0.1",
"storybook": "7.4.5", "storybook": "7.4.6",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.5", "vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.1", "vue-eslint-parser": "9.3.1",
"vue-tsc": "1.8.15" "vue-tsc": "1.8.15"

View File

@@ -215,7 +215,7 @@ const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref<any>(null); const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id));
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
const keymap = { const keymap = {

View File

@@ -20,12 +20,12 @@
"url": "git+https://github.com/misskey-dev/misskey.js.git" "url": "git+https://github.com/misskey-dev/misskey.js.git"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/api-extractor": "7.37.2", "@microsoft/api-extractor": "7.38.0",
"@swc/jest": "0.2.29", "@swc/jest": "0.2.29",
"@types/jest": "29.5.5", "@types/jest": "29.5.5",
"@types/node": "20.7.1", "@types/node": "20.8.2",
"@typescript-eslint/eslint-plugin": "6.7.3", "@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.3", "@typescript-eslint/parser": "6.7.4",
"eslint": "8.50.0", "eslint": "8.50.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-fetch-mock": "3.0.3", "jest-fetch-mock": "3.0.3",
@@ -39,7 +39,7 @@
], ],
"dependencies": { "dependencies": {
"@swc/cli": "0.1.62", "@swc/cli": "0.1.62",
"@swc/core": "1.3.90", "@swc/core": "1.3.92",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"reconnecting-websocket": "4.4.0" "reconnecting-websocket": "4.4.0"
} }

View File

@@ -14,7 +14,7 @@
"misskey-js": "workspace:*" "misskey-js": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/parser": "6.7.3", "@typescript-eslint/parser": "6.7.4",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.50.0", "eslint": "8.50.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",

1532
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff