Compare commits

..

7 Commits

Author SHA1 Message Date
syuilo
0ddd4bc545 Merge branch 'develop' into multiple-reactions 2024-08-18 13:22:22 +09:00
syuilo
cb6a1c773e Update about-misskey.vue 2024-08-17 17:52:06 +09:00
syuilo
9df887ba93 Merge branch 'develop' into multiple-reactions 2024-08-17 17:23:02 +09:00
syuilo
a2769d0733 wip 2024-07-17 17:13:01 +09:00
syuilo
036f90133c Merge branch 'develop' into multiple-reactions 2024-07-16 17:05:19 +09:00
syuilo
f9bfff604d Merge branch 'develop' into multiple-reactions 2024-07-02 14:47:30 +09:00
syuilo
fd0e840138 wip 2024-07-02 09:54:57 +09:00
32 changed files with 167 additions and 228 deletions

View File

@@ -1,15 +1,3 @@
## Unreleased
### General
- Enhance: ハイライトからセンシティブなメディアを含むノートを除外するオプション
### Client
-
### Server
-
## 2024.8.0 ## 2024.8.0
### General ### General
@@ -45,8 +33,6 @@
- Fix: 無制限にストリーミングのチャンネルに接続できる問題を修正 - Fix: 無制限にストリーミングのチャンネルに接続できる問題を修正
- Fix: ベースロールのポリシーを変更した際にモデログに記録されないのを修正 - Fix: ベースロールのポリシーを変更した際にモデログに記録されないのを修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/700) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/700)
- Fix: Prevent memory leak from memory caches (#14310)
- Fix: More reliable memory cache eviction (#14311)
## 2024.7.0 ## 2024.7.0

View File

@@ -2316,7 +2316,6 @@ _pages:
eyeCatchingImageSet: "Set thumbnail" eyeCatchingImageSet: "Set thumbnail"
eyeCatchingImageRemove: "Delete thumbnail" eyeCatchingImageRemove: "Delete thumbnail"
chooseBlock: "Add a block" chooseBlock: "Add a block"
enterSectionTitle: "Enter a section title"
selectType: "Select a type" selectType: "Select a type"
contentBlocks: "Content" contentBlocks: "Content"
inputBlocks: "Input" inputBlocks: "Input"
@@ -2500,10 +2499,7 @@ _moderationLogTypes:
createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" createAbuseReportNotificationRecipient: "Create a recipient for abuse reports"
updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" updateAbuseReportNotificationRecipient: "Update recipients for abuse reports"
deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports"
deleteAccount: "Delete the account"
deletePage: "Delete the page"
deleteFlash: "Delete Play" deleteFlash: "Delete Play"
deleteGalleryPost: "Delete the gallery post"
_fileViewer: _fileViewer:
title: "File details" title: "File details"
type: "File type" type: "File type"

4
locales/index.d.ts vendored
View File

@@ -6686,6 +6686,10 @@ export interface Locale extends ILocale {
* ノートのピン留めの最大数 * ノートのピン留めの最大数
*/ */
"pinMax": string; "pinMax": string;
/**
* 一つのノートに対する最大リアクション数
*/
"reactionsPerNoteLimit": string;
/** /**
* アンテナの作成可能数 * アンテナの作成可能数
*/ */

View File

@@ -1728,6 +1728,7 @@ _role:
alwaysMarkNsfw: "ファイルにNSFWを常に付与" alwaysMarkNsfw: "ファイルにNSFWを常に付与"
canUpdateBioMedia: "アイコンとバナーの更新を許可" canUpdateBioMedia: "アイコンとバナーの更新を許可"
pinMax: "ノートのピン留めの最大数" pinMax: "ノートのピン留めの最大数"
reactionsPerNoteLimit: "一つのノートに対する最大リアクション数"
antennaMax: "アンテナの作成可能数" antennaMax: "アンテナの作成可能数"
wordMuteMax: "ワードミュートの最大文字数" wordMuteMax: "ワードミュートの最大文字数"
webhookMax: "Webhookの作成可能数" webhookMax: "Webhookの作成可能数"

View File

@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.8.0", "version": "2024.8.0-rc.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MultipleReactions1721117896543 {
name = 'MultipleReactions1721117896543';
async up(queryRunner) {
await queryRunner.query('DROP INDEX "public"."IDX_ad0c221b25672daf2df320a817"');
await queryRunner.query('CREATE UNIQUE INDEX "IDX_a7751b74317122d11575bff31c" ON "note_reaction" ("userId", "noteId", "reaction") ');
await queryRunner.query('CREATE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") ');
}
async down(queryRunner) {
await queryRunner.query('DROP INDEX "public"."IDX_ad0c221b25672daf2df320a817"');
await queryRunner.query('DROP INDEX "public"."IDX_a7751b74317122d11575bff31c"');
await queryRunner.query('CREATE UNIQUE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") ');
}
}

View File

@@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30);
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }

View File

@@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m this.userByIdCache = new MemoryKVCache<MiUser>(Infinity);
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity);
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity);
this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity);
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', { this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown {
if (user == null) { if (user == null) {
this.userByIdCache.delete(body.id); this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id); this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.entries) { for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === body.id) { if (v.value?.id === body.id) {
this.uriPersonCache.delete(k); this.uriPersonCache.delete(k);
} }
} }
} else { } else {
this.userByIdCache.set(user.id, user); this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.entries) { for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === user.id) { if (v.value?.id === user.id) {
this.uriPersonCache.set(k, user); this.uriPersonCache.set(k, user);
} }

View File

@@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
export class CustomEmojiService implements OnApplicationShutdown { export class CustomEmojiService implements OnApplicationShutdown {
private emojisCache: MemoryKVCache<MiEmoji | null>; private cache: MemoryKVCache<MiEmoji | null>;
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>; public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;
constructor( constructor(
@@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12);
this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', { this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@@ -334,7 +334,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
host, host,
})) ?? null; })) ?? null;
const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull); const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
@@ -361,7 +361,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null); const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null);
const emojisQuery: any[] = []; const emojisQuery: any[] = [];
const hosts = new Set(notCachedEmojis.map(e => e.host)); const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) { for (const host of hosts) {
@@ -376,7 +376,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
select: ['name', 'host', 'originalUrl', 'publicUrl'], select: ['name', 'host', 'originalUrl', 'publicUrl'],
}) : []; }) : [];
for (const emoji of _emojis) { for (const emoji of _emojis) {
this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji); this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
} }
} }
@@ -401,7 +401,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.emojisCache.dispose(); this.cache.dispose();
} }
@bindThis @bindThis

View File

@@ -174,24 +174,12 @@ export class ReactionService {
reaction, reaction,
}; };
// Create reaction
try { try {
await this.noteReactionsRepository.insert(record); await this.noteReactionsRepository.insert(record);
} catch (e) { } catch (e) {
if (isDuplicateKeyValueError(e)) { if (isDuplicateKeyValueError(e)) {
const exists = await this.noteReactionsRepository.findOneByOrFail({ // 同じリアクションがすでにされていたらエラー
noteId: note.id, throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
userId: user.id,
});
if (exists.reaction !== reaction) {
// 別のリアクションがすでにされていたら置き換える
await this.delete(user, note);
await this.noteReactionsRepository.insert(record);
} else {
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
} else { } else {
throw e; throw e;
} }
@@ -286,11 +274,12 @@ export class ReactionService {
} }
@bindThis @bindThis
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, _reaction?: string | null) {
// if already unreacted // if already unreacted
const exist = await this.noteReactionsRepository.findOneBy({ const exist = await this.noteReactionsRepository.findOneBy({
noteId: note.id, noteId: note.id,
userId: user.id, userId: user.id,
reaction: _reaction ?? FALLBACK,
}); });
if (exist == null) { if (exist == null) {

View File

@@ -35,7 +35,7 @@ export class RelayService {
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
) { ) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10);
} }
@bindThis @bindThis

View File

@@ -49,6 +49,7 @@ export type RolePolicies = {
alwaysMarkNsfw: boolean; alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean; canUpdateBioMedia: boolean;
pinLimit: number; pinLimit: number;
reactionsPerNoteLimit: number;
antennaLimit: number; antennaLimit: number;
wordMuteLimit: number; wordMuteLimit: number;
webhookLimit: number; webhookLimit: number;
@@ -78,6 +79,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
alwaysMarkNsfw: false, alwaysMarkNsfw: false,
canUpdateBioMedia: true, canUpdateBioMedia: true,
pinLimit: 5, pinLimit: 5,
reactionsPerNoteLimit: 1,
antennaLimit: 5, antennaLimit: 5,
wordMuteLimit: 200, wordMuteLimit: 200,
webhookLimit: 3, webhookLimit: 3,
@@ -127,8 +129,10 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h //this.onMessage = this.onMessage.bind(this);
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }
@@ -378,6 +382,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)), pinLimit: calc('pinLimit', vs => Math.max(...vs)),
reactionsPerNoteLimit: calc('reactionsPerNoteLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),
webhookLimit: calc('webhookLimit', vs => Math.max(...vs)), webhookLimit: calc('webhookLimit', vs => Math.max(...vs)),

View File

@@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown {
) { ) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: 1000 * 60 * 60, // 1h memoryCacheLifetime: Infinity,
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),

View File

@@ -54,8 +54,8 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
} }
@bindThis @bindThis

View File

@@ -170,10 +170,10 @@ export class NoteEntityService implements OnModuleInit {
@bindThis @bindThis
public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: { public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
myReactions: Map<MiNote['id'], string | null>; myReactionsMap: Map<MiNote['id'], string | null>;
}) { }) {
if (_hint_?.myReactions) { if (_hint_?.myReactionsMap) {
const reaction = _hint_.myReactions.get(note.id); const reaction = _hint_.myReactionsMap.get(note.id);
if (reaction) { if (reaction) {
return this.reactionService.convertLegacyReaction(reaction); return this.reactionService.convertLegacyReaction(reaction);
} else { } else {

View File

@@ -7,23 +7,23 @@ import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
export class RedisKVCache<T> { export class RedisKVCache<T> {
private readonly lifetime: number; private redisClient: Redis.Redis;
private readonly memoryCache: MemoryKVCache<T>; private name: string;
private readonly fetcher: (key: string) => Promise<T>; private lifetime: number;
private readonly toRedisConverter: (value: T) => string; private memoryCache: MemoryKVCache<T>;
private readonly fromRedisConverter: (value: string) => T | undefined; private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor( constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
private redisClient: Redis.Redis, lifetime: RedisKVCache<T>['lifetime'];
private name: string, memoryCacheLifetime: number;
opts: { fetcher: RedisKVCache<T>['fetcher'];
lifetime: RedisKVCache<T>['lifetime']; toRedisConverter: RedisKVCache<T>['toRedisConverter'];
memoryCacheLifetime: number; fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
fetcher: RedisKVCache<T>['fetcher']; }) {
toRedisConverter: RedisKVCache<T>['toRedisConverter']; this.redisClient = redisClient;
fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; this.name = name;
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@@ -55,13 +55,7 @@ export class RedisKVCache<T> {
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
if (cached == null) return undefined; if (cached == null) return undefined;
return this.fromRedisConverter(cached);
const value = this.fromRedisConverter(cached);
if (value !== undefined) {
this.memoryCache.set(key, value);
}
return value;
} }
@bindThis @bindThis
@@ -72,10 +66,6 @@ export class RedisKVCache<T> {
/** /**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/ */
@bindThis @bindThis
public async fetch(key: string): Promise<T> { public async fetch(key: string): Promise<T> {
@@ -87,14 +77,14 @@ export class RedisKVCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(key); const value = await this.fetcher(key);
await this.set(key, value); this.set(key, value);
return value; return value;
} }
@bindThis @bindThis
public async refresh(key: string) { public async refresh(key: string) {
const value = await this.fetcher(key); const value = await this.fetcher(key);
await this.set(key, value); this.set(key, value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@@ -111,23 +101,23 @@ export class RedisKVCache<T> {
} }
export class RedisSingleCache<T> { export class RedisSingleCache<T> {
private readonly lifetime: number; private redisClient: Redis.Redis;
private readonly memoryCache: MemorySingleCache<T>; private name: string;
private readonly fetcher: () => Promise<T>; private lifetime: number;
private readonly toRedisConverter: (value: T) => string; private memoryCache: MemorySingleCache<T>;
private readonly fromRedisConverter: (value: string) => T | undefined; private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor( constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
private redisClient: Redis.Redis, lifetime: RedisSingleCache<T>['lifetime'];
private name: string, memoryCacheLifetime: number;
opts: { fetcher: RedisSingleCache<T>['fetcher'];
lifetime: number; toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
memoryCacheLifetime: number; fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
fetcher: RedisSingleCache<T>['fetcher']; }) {
toRedisConverter: RedisSingleCache<T>['toRedisConverter']; this.redisClient = redisClient;
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; this.name = name;
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@@ -159,13 +149,7 @@ export class RedisSingleCache<T> {
const cached = await this.redisClient.get(`singlecache:${this.name}`); const cached = await this.redisClient.get(`singlecache:${this.name}`);
if (cached == null) return undefined; if (cached == null) return undefined;
return this.fromRedisConverter(cached);
const value = this.fromRedisConverter(cached);
if (value !== undefined) {
this.memoryCache.set(value);
}
return value;
} }
@bindThis @bindThis
@@ -176,10 +160,6 @@ export class RedisSingleCache<T> {
/** /**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/ */
@bindThis @bindThis
public async fetch(): Promise<T> { public async fetch(): Promise<T> {
@@ -191,14 +171,14 @@ export class RedisSingleCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(); const value = await this.fetcher();
await this.set(value); this.set(value);
return value; return value;
} }
@bindThis @bindThis
public async refresh() { public async refresh() {
const value = await this.fetcher(); const value = await this.fetcher();
await this.set(value); this.set(value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@@ -207,12 +187,22 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> { export class MemoryKVCache<T> {
private readonly cache = new Map<string, { date: number; value: T; }>(); /**
private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m * データを持つマップ
* @deprecated これを直接操作するべきではない
*/
public cache: Map<string, { date: number; value: T; }>;
private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout;
constructor( constructor(lifetime: MemoryKVCache<never>['lifetime']) {
private readonly lifetime: number, this.cache = new Map();
) {} this.lifetime = lifetime;
this.gcIntervalHandle = setInterval(() => {
this.gc();
}, 1000 * 60 * 3);
}
@bindThis @bindThis
/** /**
@@ -297,14 +287,10 @@ export class MemoryKVCache<T> {
@bindThis @bindThis
public gc(): void { public gc(): void {
const now = Date.now(); const now = Date.now();
for (const [key, { date }] of this.cache.entries()) { for (const [key, { date }] of this.cache.entries()) {
// The map is ordered from oldest to youngest. if ((now - date) > this.lifetime) {
// We can stop once we find an entry that's still active, because all following entries must *also* be active. this.cache.delete(key);
const age = now - date; }
if (age < this.lifetime) break;
this.cache.delete(key);
} }
} }
@@ -312,19 +298,16 @@ export class MemoryKVCache<T> {
public dispose(): void { public dispose(): void {
clearInterval(this.gcIntervalHandle); clearInterval(this.gcIntervalHandle);
} }
public get entries() {
return this.cache.entries();
}
} }
export class MemorySingleCache<T> { export class MemorySingleCache<T> {
private cachedAt: number | null = null; private cachedAt: number | null = null;
private value: T | undefined; private value: T | undefined;
private lifetime: number;
constructor( constructor(lifetime: MemorySingleCache<never>['lifetime']) {
private lifetime: number, this.lifetime = lifetime;
) {} }
@bindThis @bindThis
public set(value: T): void { public set(value: T): void {

View File

@@ -9,7 +9,8 @@ import { MiUser } from './User.js';
import { MiNote } from './Note.js'; import { MiNote } from './Note.js';
@Entity('note_reaction') @Entity('note_reaction')
@Index(['userId', 'noteId'], { unique: true }) @Index(['userId', 'noteId'])
@Index(['userId', 'noteId', 'reaction'], { unique: true })
export class MiNoteReaction { export class MiNoteReaction {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;

View File

@@ -236,6 +236,10 @@ export const packedRolePoliciesSchema = {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,
}, },
reactionsPerNoteLimit: {
type: 'integer',
optional: false, nullable: false,
},
antennaLimit: { antennaLimit: {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,

View File

@@ -45,7 +45,7 @@ export class DeliverProcessorService {
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); // 1h this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60);
} }
@bindThis @bindThis

View File

@@ -134,7 +134,7 @@ export class NodeinfoServerService {
return document; return document;
}; };
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
fastify.get(nodeinfo2_1path, async (request, reply) => { fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2(21)); const base = await cache.fetch(() => nodeinfo2(21));

View File

@@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
) { ) {
this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w this.appCache = new MemoryKVCache<MiApp>(Infinity);
} }
@bindThis @bindThis

View File

@@ -36,7 +36,6 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
channelId: { type: 'string', nullable: true, format: 'misskey:id' }, channelId: { type: 'string', nullable: true, format: 'misskey:id' },
withSensitive: { type: 'boolean', default: true },
}, },
required: [], required: [],
} as const; } as const;
@@ -104,13 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
notes.sort((a, b) => a.id > b.id ? -1 : 1); notes.sort((a, b) => a.id > b.id ? -1 : 1);
let packed = await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
if (!ps.withSensitive) {
packed = packed.filter(note => note.files?.length === 0 || note.files?.every(file => !file.isSensitive));
}
return packed;
}); });
} }
} }

View File

@@ -89,6 +89,7 @@ export const ROLE_POLICIES = [
'alwaysMarkNsfw', 'alwaysMarkNsfw',
'canUpdateBioMedia', 'canUpdateBioMedia',
'pinLimit', 'pinLimit',
'reactionsPerNoteLimit',
'antennaLimit', 'antennaLimit',
'wordMuteLimit', 'wordMuteLimit',
'webhookLimit', 'webhookLimit',

View File

@@ -417,6 +417,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.reactionsPerNoteLimit, 'reactionsPerNoteLimit'])">
<template #label>{{ i18n.ts._role._options.reactionsPerNoteLimit }}</template>
<template #suffix>
<span v-if="role.policies.reactionsPerNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.reactionsPerNoteLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.reactionsPerNoteLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.reactionsPerNoteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.reactionsPerNoteLimit.value" :disabled="role.policies.reactionsPerNoteLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.reactionsPerNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])">
<template #label>{{ i18n.ts._role._options.antennaMax }}</template> <template #label>{{ i18n.ts._role._options.antennaMax }}</template>
<template #suffix> <template #suffix>

View File

@@ -149,6 +149,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.reactionsPerNoteLimit, 'reactionsPerNoteLimit'])">
<template #label>{{ i18n.ts._role._options.reactionsPerNoteLimit }}</template>
<template #suffix>{{ policies.reactionsPerNoteLimit }}</template>
<MkInput v-model="policies.reactionsPerNoteLimit" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])">
<template #label>{{ i18n.ts._role._options.antennaMax }}</template> <template #label>{{ i18n.ts._role._options.antennaMax }}</template>
<template #suffix>{{ policies.antennaLimit }}</template> <template #suffix>{{ policies.antennaLimit }}</template>

View File

@@ -7,11 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<MkTab v-model="tab" style="margin-bottom: var(--margin);"> <MkTab v-model="tab" style="margin-bottom: var(--margin);">
<option value="notes">{{ i18n.ts.notes }}</option> <option value="notes">{{ i18n.ts.notes }}</option>
<option value="notesWithSensitive">{{ i18n.ts.notes }} (+{{ i18n.ts.sensitive }})</option>
<option value="polls">{{ i18n.ts.poll }}</option> <option value="polls">{{ i18n.ts.poll }}</option>
</MkTab> </MkTab>
<MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
<MkNotes v-else-if="tab === 'notesWithSensitive'" :pagination="paginationForNotesWithSensitive"/>
<MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
</MkSpacer> </MkSpacer>
</template> </template>
@@ -25,17 +23,6 @@ import { i18n } from '@/i18n.js';
const paginationForNotes = { const paginationForNotes = {
endpoint: 'notes/featured' as const, endpoint: 'notes/featured' as const,
limit: 10, limit: 10,
params: {
withSensitive: false,
},
};
const paginationForNotesWithSensitive = {
endpoint: 'notes/featured' as const,
limit: 10,
params: {
withSensitive: true,
},
}; };
const paginationForPolls = { const paginationForPolls = {

View File

@@ -16,57 +16,21 @@ function containsFocusTrappedElements(el: HTMLElement): boolean {
}); });
} }
function getZIndex(el: HTMLElement): number {
const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10);
if (isNaN(zIndex)) {
return 0;
}
return zIndex;
}
function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null {
let highestZIndexElement: HTMLElement | null = null;
let highestZIndex = -Infinity;
focusTrapElements.forEach((el) => {
const zIndex = getZIndex(el);
if (zIndex > highestZIndex) {
highestZIndex = zIndex;
highestZIndexElement = el;
}
});
return highestZIndexElement == null ? null : {
el: highestZIndexElement,
zIndex: highestZIndex,
};
}
function releaseFocusTrap(el: HTMLElement): void { function releaseFocusTrap(el: HTMLElement): void {
focusTrapElements.delete(el); focusTrapElements.delete(el);
if (el.inert === true) { if (el.inert === true) {
el.inert = false; el.inert = false;
} }
const highestZIndexElement = getHighestZIndexElement();
if (el.parentElement != null && el !== document.body) { if (el.parentElement != null && el !== document.body) {
el.parentElement.childNodes.forEach((siblingNode) => { el.parentElement.childNodes.forEach((siblingNode) => {
const siblingEl = getHTMLElementOrNull(siblingNode); const siblingEl = getHTMLElementOrNull(siblingNode);
if (!siblingEl) return; if (!siblingEl) return;
if ( if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) {
siblingEl !== el &&
(
highestZIndexElement == null ||
siblingEl === highestZIndexElement.el ||
siblingEl.contains(highestZIndexElement.el)
)
) {
siblingEl.inert = false; siblingEl.inert = false;
} else if ( } else if (
highestZIndexElement != null && focusTrapElements.size > 0 &&
siblingEl !== highestZIndexElement.el && !containsFocusTrappedElements(siblingEl) &&
!siblingEl.contains(highestZIndexElement.el) && !focusTrapElements.has(siblingEl) &&
!ignoreElements.includes(siblingEl.tagName.toLowerCase()) !ignoreElements.includes(siblingEl.tagName.toLowerCase())
) { ) {
siblingEl.inert = true; siblingEl.inert = true;
@@ -81,29 +45,9 @@ function releaseFocusTrap(el: HTMLElement): void {
export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void;
export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; }; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; };
export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void { export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void {
const highestZIndexElement = getHighestZIndexElement();
const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex;
const zIndex = getZIndex(el);
// If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead
// Focus trapping for this element will be done in the release function
if (!parent && zIndex < highestZIndex) {
focusTrapElements.add(el);
if (highestZIndexElement) {
focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls);
}
return {
release: () => {
releaseFocusTrap(el);
},
};
}
if (el.inert === true) { if (el.inert === true) {
el.inert = false; el.inert = false;
} }
if (el.parentElement != null && el !== document.body) { if (el.parentElement != null && el !== document.body) {
el.parentElement.childNodes.forEach((siblingNode) => { el.parentElement.childNodes.forEach((siblingNode) => {
const siblingEl = getHTMLElementOrNull(siblingNode); const siblingEl = getHTMLElementOrNull(siblingNode);

View File

@@ -89,6 +89,7 @@
X11: 'rgba(0, 0, 0, 0.3)', X11: 'rgba(0, 0, 0, 0.3)',
X12: 'rgba(255, 255, 255, 0.1)', X12: 'rgba(255, 255, 255, 0.1)',
X13: 'rgba(255, 255, 255, 0.15)', X13: 'rgba(255, 255, 255, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel', X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel', X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg', X17: ':alpha<0.8<@bg',

View File

@@ -89,6 +89,7 @@
X11: 'rgba(0, 0, 0, 0.1)', X11: 'rgba(0, 0, 0, 0.1)',
X12: 'rgba(0, 0, 0, 0.1)', X12: 'rgba(0, 0, 0, 0.1)',
X13: 'rgba(0, 0, 0, 0.15)', X13: 'rgba(0, 0, 0, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel', X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel', X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg', X17: ':alpha<0.8<@bg',

View File

@@ -82,8 +82,6 @@ function more() {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
--nav-bg-transparent: color-mix(in srgb, var(--navBg), transparent 50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -93,7 +91,7 @@ function more() {
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 20px 0; padding: 20px 0;
background: var(--nav-bg-transparent); background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@@ -127,7 +125,7 @@ function more() {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding: 20px 0; padding: 20px 0;
background: var(--nav-bg-transparent); background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }

View File

@@ -111,7 +111,6 @@ function more(ev: MouseEvent) {
.root { .root {
--nav-width: 250px; --nav-width: 250px;
--nav-icon-only-width: 80px; --nav-icon-only-width: 80px;
--nav-bg-transparent: color-mix(in srgb, var(--navBg), transparent 50%);
flex: 0 0 var(--nav-width); flex: 0 0 var(--nav-width);
width: var(--nav-width); width: var(--nav-width);
@@ -145,7 +144,7 @@ function more(ev: MouseEvent) {
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 20px 0; padding: 20px 0;
background: var(--nav-bg-transparent); background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@@ -188,7 +187,7 @@ function more(ev: MouseEvent) {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding-top: 20px; padding-top: 20px;
background: var(--nav-bg-transparent); background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@@ -379,7 +378,7 @@ function more(ev: MouseEvent) {
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 20px 0; padding: 20px 0;
background: var(--nav-bg-transparent); background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@@ -409,7 +408,7 @@ function more(ev: MouseEvent) {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding-top: 20px; padding-top: 20px;
background: var(--nav-bg-transparent); background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2024.8.0", "version": "2024.8.0-rc.3",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",