From 947163fde2806649c597b4e023a33900edcbc28a Mon Sep 17 00:00:00 2001
From: Namekuji <nmkj@waah.day>
Date: Sun, 2 Jul 2023 20:37:46 -0400
Subject: [PATCH 01/11] store cache values to redis

---
 packages/backend/package.json                 |  2 +-
 packages/backend/src/misc/cache.ts            | 86 +++++++++++++------
 packages/backend/src/misc/emoji-meta.ts       | 34 +++++---
 packages/backend/src/misc/populate-emojis.ts  |  9 +-
 .../src/remote/activitypub/models/person.ts   |  6 +-
 .../src/services/chart/charts/active-users.ts |  6 +-
 .../backend/src/services/instance-actor.ts    |  6 +-
 .../register-or-fetch-instance-doc.ts         | 12 +--
 packages/backend/src/services/relay.ts        |  2 +-
 packages/backend/src/services/user-cache.ts   | 33 +++----
 pnpm-lock.yaml                                | 23 +++--
 11 files changed, 131 insertions(+), 88 deletions(-)

diff --git a/packages/backend/package.json b/packages/backend/package.json
index b584a56910..6f63441023 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -34,6 +34,7 @@
 		"@koa/cors": "3.4.3",
 		"@koa/multer": "3.0.2",
 		"@koa/router": "9.0.1",
+		"@msgpack/msgpack": "3.0.0-beta2",
 		"@peertube/http-signature": "1.7.0",
 		"@redocly/openapi-core": "1.0.0-beta.120",
 		"@sinonjs/fake-timers": "9.1.2",
@@ -43,7 +44,6 @@
 		"ajv": "8.12.0",
 		"archiver": "5.3.1",
 		"argon2": "^0.30.3",
-		"async-mutex": "^0.4.0",
 		"autobind-decorator": "2.4.0",
 		"autolinker": "4.0.0",
 		"autwh": "0.1.0",
diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index 9abebc91cb..d790313d18 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -1,43 +1,75 @@
+import { redisClient } from "@/db/redis.js";
+import { nativeRandomStr } from "native-utils/built/index.js";
+import { encode, decode } from "@msgpack/msgpack";
+import { ChainableCommander } from "ioredis";
+
 export class Cache<T> {
-	public cache: Map<string | null, { date: number; value: T }>;
-	private lifetime: number;
+	private ttl: number;
+	private fingerprint: string;
 
-	constructor(lifetime: Cache<never>["lifetime"]) {
-		this.cache = new Map();
-		this.lifetime = lifetime;
+	constructor(ttl: number) {
+		this.ttl = ttl;
+		this.fingerprint = `cache:${nativeRandomStr(32)}`;
 	}
 
-	public set(key: string | null, value: T): void {
-		this.cache.set(key, {
-			date: Date.now(),
-			value,
-		});
+	private prefixedKey(key: string | null): string {
+		return key ? `${this.fingerprint}:${key}` : this.fingerprint;
 	}
 
-	public get(key: string | null): T | undefined {
-		const cached = this.cache.get(key);
-		if (cached == null) return undefined;
-		if (Date.now() - cached.date > this.lifetime) {
-			this.cache.delete(key);
-			return undefined;
+	public async set(key: string | null, value: T, transaction?: ChainableCommander): Promise<void> {
+		const _key = this.prefixedKey(key);
+		const _value = Buffer.from(encode(value));
+		const commander = transaction ?? redisClient;
+		if (this.ttl === Infinity) {
+			await commander.set(_key, _value);
+		} else {
+			await commander.set(_key, _value, "PX", this.ttl);
 		}
-		return cached.value;
 	}
 
-	public delete(key: string | null) {
-		this.cache.delete(key);
+	public async get(key: string | null): Promise<T | undefined> {
+		const _key = this.prefixedKey(key);
+		const cached = await redisClient.getBuffer(_key);
+		if (cached === null) return undefined;
+
+		return decode(cached) as T;
+	}
+
+	public async getAll(): Promise<Map<string, T>> {
+		const keys = await redisClient.keys(`${this.fingerprint}*`);
+		const map = new Map<string, T>();
+		if (keys.length === 0) {
+			return map;
+		}
+		const values = await redisClient.mgetBuffer(keys);
+
+		for (const [i, key] of keys.entries()) {
+			const val = values[i];
+			if (val !== null) {
+				map.set(key, decode(val) as T);
+			}
+		}
+
+		return map;
+	}
+
+	public async delete(...keys: (string | null)[]): Promise<void> {
+		if (keys.length > 0) {
+			const _keys = keys.map(this.prefixedKey);
+			await redisClient.del(_keys);
+		}
 	}
 
 	/**
-	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
-	 * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
+	 * Returns if cached value exists. Otherwise, calls fetcher and caches.
+	 * Overwrites cached value if invalidated by the optional validator.
 	 */
 	public async fetch(
 		key: string | null,
 		fetcher: () => Promise<T>,
 		validator?: (cachedValue: T) => boolean,
 	): Promise<T> {
-		const cachedValue = this.get(key);
+		const cachedValue = await this.get(key);
 		if (cachedValue !== undefined) {
 			if (validator) {
 				if (validator(cachedValue)) {
@@ -52,20 +84,20 @@ export class Cache<T> {
 
 		// Cache MISS
 		const value = await fetcher();
-		this.set(key, value);
+		await this.set(key, value);
 		return value;
 	}
 
 	/**
-	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
-	 * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
+	 * Returns if cached value exists. Otherwise, calls fetcher and caches if the fetcher returns a value.
+	 * Overwrites cached value if invalidated by the optional validator.
 	 */
 	public async fetchMaybe(
 		key: string | null,
 		fetcher: () => Promise<T | undefined>,
 		validator?: (cachedValue: T) => boolean,
 	): Promise<T | undefined> {
-		const cachedValue = this.get(key);
+		const cachedValue = await this.get(key);
 		if (cachedValue !== undefined) {
 			if (validator) {
 				if (validator(cachedValue)) {
@@ -81,7 +113,7 @@ export class Cache<T> {
 		// Cache MISS
 		const value = await fetcher();
 		if (value !== undefined) {
-			this.set(key, value);
+			await this.set(key, value);
 		}
 		return value;
 	}
diff --git a/packages/backend/src/misc/emoji-meta.ts b/packages/backend/src/misc/emoji-meta.ts
index fd9d9baa5c..45364bdcbc 100644
--- a/packages/backend/src/misc/emoji-meta.ts
+++ b/packages/backend/src/misc/emoji-meta.ts
@@ -1,9 +1,10 @@
 import probeImageSize from "probe-image-size";
-import { Mutex, withTimeout } from "async-mutex";
+import { Mutex } from "redis-semaphore";
 
 import { FILE_TYPE_BROWSERSAFE } from "@/const.js";
 import Logger from "@/services/logger.js";
 import { Cache } from "./cache.js";
+import { redisClient } from "@/db/redis.js";
 
 export type Size = {
 	width: number;
@@ -11,23 +12,30 @@ export type Size = {
 };
 
 const cache = new Cache<boolean>(1000 * 60 * 10); // once every 10 minutes for the same url
-const mutex = withTimeout(new Mutex(), 1000);
+const logger = new Logger("emoji");
 
 export async function getEmojiSize(url: string): Promise<Size> {
-	const logger = new Logger("emoji");
+	let attempted = true;
 
-	await mutex.runExclusive(() => {
-		const attempted = cache.get(url);
-		if (!attempted) {
-			cache.set(url, true);
-		} else {
-			logger.warn(`Attempt limit exceeded: ${url}`);
-			throw new Error("Too many attempts");
-		}
-	});
+	const lock = new Mutex(redisClient, "getEmojiSize");
+	await lock.acquire();
 
 	try {
-		logger.info(`Retrieving emoji size from ${url}`);
+		attempted = (await cache.get(url)) === true;
+		if (!attempted) {
+			await cache.set(url, true);
+		}
+	} finally {
+		await lock.release();
+	}
+
+	if (attempted) {
+		logger.warn(`Attempt limit exceeded: ${url}`);
+		throw new Error("Too many attempts");
+	}
+
+	try {
+		logger.debug(`Retrieving emoji size from ${url}`);
 		const { width, height, mime } = await probeImageSize(url, {
 			timeout: 5000,
 		});
diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts
index 7aee4ec253..ce25dd5594 100644
--- a/packages/backend/src/misc/populate-emojis.ts
+++ b/packages/backend/src/misc/populate-emojis.ts
@@ -7,6 +7,7 @@ import { isSelfHost, toPunyNullable } from "./convert-host.js";
 import { decodeReaction } from "./reaction-lib.js";
 import config from "@/config/index.js";
 import { query } from "@/prelude/url.js";
+import { redisClient } from "@/db/redis.js";
 
 const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
 
@@ -75,7 +76,7 @@ export async function populateEmoji(
 
 	if (emoji && !(emoji.width && emoji.height)) {
 		emoji = await queryOrNull();
-		cache.set(cacheKey, emoji);
+		await cache.set(cacheKey, emoji);
 	}
 
 	if (emoji == null) return null;
@@ -150,7 +151,7 @@ export async function prefetchEmojis(
 	emojis: { name: string; host: string | null }[],
 ): Promise<void> {
 	const notCachedEmojis = emojis.filter(
-		(emoji) => cache.get(`${emoji.name} ${emoji.host}`) == null,
+		async (emoji) => !(await cache.get(`${emoji.name} ${emoji.host}`)),
 	);
 	const emojisQuery: any[] = [];
 	const hosts = new Set(notCachedEmojis.map((e) => e.host));
@@ -169,7 +170,9 @@ export async function prefetchEmojis(
 					select: ["name", "host", "originalUrl", "publicUrl"],
 			  })
 			: [];
+	const trans = redisClient.multi();
 	for (const emoji of _emojis) {
-		cache.set(`${emoji.name} ${emoji.host}`, emoji);
+		cache.set(`${emoji.name} ${emoji.host}`, emoji, trans);
 	}
+	await trans.exec();
 }
diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts
index f8208e6d7b..c541e9ae50 100644
--- a/packages/backend/src/remote/activitypub/models/person.ts
+++ b/packages/backend/src/remote/activitypub/models/person.ts
@@ -135,14 +135,14 @@ export async function fetchPerson(
 ): Promise<CacheableUser | null> {
 	if (typeof uri !== "string") throw new Error("uri is not string");
 
-	const cached = uriPersonCache.get(uri);
+	const cached = await uriPersonCache.get(uri);
 	if (cached) return cached;
 
 	// Fetch from the database if the URI points to this server
 	if (uri.startsWith(`${config.url}/`)) {
 		const id = uri.split("/").pop();
 		const u = await Users.findOneBy({ id });
-		if (u) uriPersonCache.set(uri, u);
+		if (u) await uriPersonCache.set(uri, u);
 		return u;
 	}
 
@@ -150,7 +150,7 @@ export async function fetchPerson(
 	const exist = await Users.findOneBy({ uri });
 
 	if (exist) {
-		uriPersonCache.set(uri, exist);
+		await uriPersonCache.set(uri, exist);
 		return exist;
 	}
 	//#endregion
diff --git a/packages/backend/src/services/chart/charts/active-users.ts b/packages/backend/src/services/chart/charts/active-users.ts
index 7a0c45cfaf..15317e68b0 100644
--- a/packages/backend/src/services/chart/charts/active-users.ts
+++ b/packages/backend/src/services/chart/charts/active-users.ts
@@ -25,12 +25,12 @@ export default class ActiveUsersChart extends Chart<typeof schema> {
 		return {};
 	}
 
-	public async read(user: {
+	public read(user: {
 		id: User["id"];
 		host: null;
 		createdAt: User["createdAt"];
-	}): Promise<void> {
-		await this.commit({
+	})  {
+		this.commit({
 			read: [user.id],
 			registeredWithinWeek:
 				Date.now() - user.createdAt.getTime() < week ? [user.id] : [],
diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts
index 50ce227eba..9240f31073 100644
--- a/packages/backend/src/services/instance-actor.ts
+++ b/packages/backend/src/services/instance-actor.ts
@@ -9,7 +9,7 @@ const ACTOR_USERNAME = "instance.actor" as const;
 const cache = new Cache<ILocalUser>(Infinity);
 
 export async function getInstanceActor(): Promise<ILocalUser> {
-	const cached = cache.get(null);
+	const cached = await cache.get(null);
 	if (cached) return cached;
 
 	const user = (await Users.findOneBy({
@@ -18,11 +18,11 @@ export async function getInstanceActor(): Promise<ILocalUser> {
 	})) as ILocalUser | undefined;
 
 	if (user) {
-		cache.set(null, user);
+		await cache.set(null, user);
 		return user;
 	} else {
 		const created = (await createSystemUser(ACTOR_USERNAME)) as ILocalUser;
-		cache.set(null, created);
+		await cache.set(null, created);
 		return created;
 	}
 }
diff --git a/packages/backend/src/services/register-or-fetch-instance-doc.ts b/packages/backend/src/services/register-or-fetch-instance-doc.ts
index 4c3570e907..ddb9ce2413 100644
--- a/packages/backend/src/services/register-or-fetch-instance-doc.ts
+++ b/packages/backend/src/services/register-or-fetch-instance-doc.ts
@@ -9,25 +9,25 @@ const cache = new Cache<Instance>(1000 * 60 * 60);
 export async function registerOrFetchInstanceDoc(
 	host: string,
 ): Promise<Instance> {
-	host = toPuny(host);
+	const _host = toPuny(host);
 
-	const cached = cache.get(host);
+	const cached = await cache.get(_host);
 	if (cached) return cached;
 
-	const index = await Instances.findOneBy({ host });
+	const index = await Instances.findOneBy({ host: _host });
 
 	if (index == null) {
 		const i = await Instances.insert({
 			id: genId(),
-			host,
+			host: _host,
 			caughtAt: new Date(),
 			lastCommunicatedAt: new Date(),
 		}).then((x) => Instances.findOneByOrFail(x.identifiers[0]));
 
-		cache.set(host, i);
+		await cache.set(_host, i);
 		return i;
 	} else {
-		cache.set(host, index);
+		await cache.set(_host, index);
 		return index;
 	}
 }
diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts
index bec4b1f86b..2325f76c69 100644
--- a/packages/backend/src/services/relay.ts
+++ b/packages/backend/src/services/relay.ts
@@ -90,7 +90,7 @@ async function updateRelaysCache() {
 	const relays = await Relays.findBy({
 		status: "accepted",
 	});
-	relaysCache.set(null, relays);
+	await relaysCache.set(null, relays);
 }
 
 export async function relayRejected(id: string) {
diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts
index 9492448554..373fb86869 100644
--- a/packages/backend/src/services/user-cache.ts
+++ b/packages/backend/src/services/user-cache.ts
@@ -6,7 +6,7 @@ import type {
 import { User } from "@/models/entities/user.js";
 import { Users } from "@/models/index.js";
 import { Cache } from "@/misc/cache.js";
-import { subscriber } from "@/db/redis.js";
+import { redisClient, subscriber } from "@/db/redis.js";
 
 export const userByIdCache = new Cache<CacheableUser>(Infinity);
 export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(
@@ -22,13 +22,12 @@ subscriber.on("message", async (_, data) => {
 		const { type, body } = obj.message;
 		switch (type) {
 			case "localUserUpdated": {
-				userByIdCache.delete(body.id);
-				localUserByIdCache.delete(body.id);
-				localUserByNativeTokenCache.cache.forEach((v, k) => {
-					if (v.value?.id === body.id) {
-						localUserByNativeTokenCache.delete(k);
-					}
-				});
+				await userByIdCache.delete(body.id);
+				await localUserByIdCache.delete(body.id);
+				const toDelete = Array.from(await localUserByNativeTokenCache.getAll())
+					.filter((v) => v[1]?.id === body.id)
+					.map((v) => v[0]);
+				await localUserByNativeTokenCache.delete(...toDelete);
 				break;
 			}
 			case "userChangeSuspendedState":
@@ -36,15 +35,17 @@ subscriber.on("message", async (_, data) => {
 			case "userChangeModeratorState":
 			case "remoteUserUpdated": {
 				const user = await Users.findOneByOrFail({ id: body.id });
-				userByIdCache.set(user.id, user);
-				for (const [k, v] of uriPersonCache.cache.entries()) {
-					if (v.value?.id === user.id) {
-						uriPersonCache.set(k, user);
+				await userByIdCache.set(user.id, user);
+				const trans = redisClient.multi();
+				for (const [k, v] of (await uriPersonCache.getAll()).entries()) {
+					if (v?.id === user.id) {
+						await uriPersonCache.set(k, user, trans);
 					}
 				}
+				await trans.exec();
 				if (Users.isLocalUser(user)) {
-					localUserByNativeTokenCache.set(user.token, user);
-					localUserByIdCache.set(user.id, user);
+					await localUserByNativeTokenCache.set(user.token, user);
+					await localUserByIdCache.set(user.id, user);
 				}
 				break;
 			}
@@ -52,8 +53,8 @@ subscriber.on("message", async (_, data) => {
 				const user = (await Users.findOneByOrFail({
 					id: body.id,
 				})) as ILocalUser;
-				localUserByNativeTokenCache.delete(body.oldToken);
-				localUserByNativeTokenCache.set(body.newToken, user);
+				await localUserByNativeTokenCache.delete(body.oldToken);
+				await localUserByNativeTokenCache.set(body.newToken, user);
 				break;
 			}
 			default:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1002a6f958..560bb55a37 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -105,6 +105,9 @@ importers:
       '@koa/router':
         specifier: 9.0.1
         version: 9.0.1
+      '@msgpack/msgpack':
+        specifier: 3.0.0-beta2
+        version: 3.0.0-beta2
       '@peertube/http-signature':
         specifier: 1.7.0
         version: 1.7.0
@@ -132,9 +135,6 @@ importers:
       argon2:
         specifier: ^0.30.3
         version: 0.30.3
-      async-mutex:
-        specifier: ^0.4.0
-        version: 0.4.0
       autobind-decorator:
         specifier: 2.4.0
         version: 2.4.0
@@ -786,7 +786,7 @@ importers:
         version: 2.30.0
       emojilib:
         specifier: github:thatonecalculator/emojilib
-        version: github.com/thatonecalculator/emojilib/542fcc1a25003afad78f3248ceee8ac6980ddeb8
+        version: github.com/thatonecalculator/emojilib/06944984a61ee799b7083894258f5fa318d932d1
       escape-regexp:
         specifier: 0.0.1
         version: 0.0.1
@@ -2277,6 +2277,11 @@ packages:
       os-filter-obj: 2.0.0
     dev: true
 
+  /@msgpack/msgpack@3.0.0-beta2:
+    resolution: {integrity: sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==}
+    engines: {node: '>= 14'}
+    dev: false
+
   /@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2:
     resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==}
     cpu: [arm64]
@@ -4496,12 +4501,6 @@ packages:
       stream-exhaust: 1.0.2
     dev: true
 
-  /async-mutex@0.4.0:
-    resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==}
-    dependencies:
-      tslib: 2.6.0
-    dev: false
-
   /async-settle@1.0.0:
     resolution: {integrity: sha512-VPXfB4Vk49z1LHHodrEQ6Xf7W4gg1w0dAPROHngx7qgDjqmIQ+fXmwgGXTW/ITLai0YLSvWepJOP9EVpMnEAcw==}
     engines: {node: '>= 0.10'}
@@ -15772,8 +15771,8 @@ packages:
       url-polyfill: 1.1.12
     dev: true
 
-  github.com/thatonecalculator/emojilib/542fcc1a25003afad78f3248ceee8ac6980ddeb8:
-    resolution: {tarball: https://codeload.github.com/thatonecalculator/emojilib/tar.gz/542fcc1a25003afad78f3248ceee8ac6980ddeb8}
+  github.com/thatonecalculator/emojilib/06944984a61ee799b7083894258f5fa318d932d1:
+    resolution: {tarball: https://codeload.github.com/thatonecalculator/emojilib/tar.gz/06944984a61ee799b7083894258f5fa318d932d1}
     name: emojilib
     version: 3.0.10
     dev: true

From 76c9422d538bd28081bf952741b4b7c629afb809 Mon Sep 17 00:00:00 2001
From: Namekuji <nmkj@waah.day>
Date: Sun, 2 Jul 2023 20:55:20 -0400
Subject: [PATCH 02/11] add cache prefix

---
 packages/backend/src/misc/cache.ts                    | 11 +++++------
 packages/backend/src/misc/check-hit-antenna.ts        |  2 +-
 packages/backend/src/misc/emoji-meta.ts               |  2 +-
 packages/backend/src/misc/keypair-store.ts            |  2 +-
 packages/backend/src/misc/populate-emojis.ts          |  2 +-
 packages/backend/src/models/repositories/user.ts      |  2 +-
 .../backend/src/remote/activitypub/db-resolver.ts     |  4 ++--
 packages/backend/src/server/api/authenticate.ts       |  2 +-
 packages/backend/src/server/nodeinfo.ts               |  2 +-
 packages/backend/src/services/instance-actor.ts       |  2 +-
 packages/backend/src/services/note/create.ts          |  2 +-
 .../src/services/register-or-fetch-instance-doc.ts    |  2 +-
 packages/backend/src/services/relay.ts                |  2 +-
 packages/backend/src/services/user-cache.ts           |  8 ++++----
 14 files changed, 22 insertions(+), 23 deletions(-)

diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index d790313d18..c2695aa789 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -1,19 +1,18 @@
 import { redisClient } from "@/db/redis.js";
-import { nativeRandomStr } from "native-utils/built/index.js";
 import { encode, decode } from "@msgpack/msgpack";
 import { ChainableCommander } from "ioredis";
 
 export class Cache<T> {
 	private ttl: number;
-	private fingerprint: string;
+	private prefix: string;
 
-	constructor(ttl: number) {
+	constructor(prefix: string, ttl: number) {
 		this.ttl = ttl;
-		this.fingerprint = `cache:${nativeRandomStr(32)}`;
+		this.prefix = `cache:${prefix}`;
 	}
 
 	private prefixedKey(key: string | null): string {
-		return key ? `${this.fingerprint}:${key}` : this.fingerprint;
+		return key ? `${this.prefix}:${key}` : this.prefix;
 	}
 
 	public async set(key: string | null, value: T, transaction?: ChainableCommander): Promise<void> {
@@ -36,7 +35,7 @@ export class Cache<T> {
 	}
 
 	public async getAll(): Promise<Map<string, T>> {
-		const keys = await redisClient.keys(`${this.fingerprint}*`);
+		const keys = await redisClient.keys(`${this.prefix}*`);
 		const map = new Map<string, T>();
 		if (keys.length === 0) {
 			return map;
diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts
index 358fba0f37..c422cca943 100644
--- a/packages/backend/src/misc/check-hit-antenna.ts
+++ b/packages/backend/src/misc/check-hit-antenna.ts
@@ -11,7 +11,7 @@ import * as Acct from "@/misc/acct.js";
 import type { Packed } from "./schema.js";
 import { Cache } from "./cache.js";
 
-const blockingCache = new Cache<User["id"][]>(1000 * 60 * 5);
+const blockingCache = new Cache<User["id"][]>("blocking", 1000 * 60 * 5);
 
 // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
 
diff --git a/packages/backend/src/misc/emoji-meta.ts b/packages/backend/src/misc/emoji-meta.ts
index 45364bdcbc..d2d15411f7 100644
--- a/packages/backend/src/misc/emoji-meta.ts
+++ b/packages/backend/src/misc/emoji-meta.ts
@@ -11,7 +11,7 @@ export type Size = {
 	height: number;
 };
 
-const cache = new Cache<boolean>(1000 * 60 * 10); // once every 10 minutes for the same url
+const cache = new Cache<boolean>("emojiMeta",1000 * 60 * 10); // once every 10 minutes for the same url
 const logger = new Logger("emoji");
 
 export async function getEmojiSize(url: string): Promise<Size> {
diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts
index 4551bfd988..b0e07c4ab4 100644
--- a/packages/backend/src/misc/keypair-store.ts
+++ b/packages/backend/src/misc/keypair-store.ts
@@ -3,7 +3,7 @@ import type { User } from "@/models/entities/user.js";
 import type { UserKeypair } from "@/models/entities/user-keypair.js";
 import { Cache } from "./cache.js";
 
-const cache = new Cache<UserKeypair>(Infinity);
+const cache = new Cache<UserKeypair>("keypairStore", Infinity);
 
 export async function getUserKeypair(userId: User["id"]): Promise<UserKeypair> {
 	return await cache.fetch(userId, () =>
diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts
index ce25dd5594..e6e6c2fb90 100644
--- a/packages/backend/src/misc/populate-emojis.ts
+++ b/packages/backend/src/misc/populate-emojis.ts
@@ -9,7 +9,7 @@ import config from "@/config/index.js";
 import { query } from "@/prelude/url.js";
 import { redisClient } from "@/db/redis.js";
 
-const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
+const cache = new Cache<Emoji | null>("populateEmojis", 1000 * 60 * 60 * 12);
 
 /**
  * 添付用絵文字情報
diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts
index 48c8d75b3b..2bd8d4fbaa 100644
--- a/packages/backend/src/models/repositories/user.ts
+++ b/packages/backend/src/models/repositories/user.ts
@@ -40,7 +40,7 @@ import {
 } from "../index.js";
 import type { Instance } from "../entities/instance.js";
 
-const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
+const userInstanceCache = new Cache<Instance | null>("userInstance", 1000 * 60 * 60 * 3);
 
 type IsUserDetailed<Detailed extends boolean> = Detailed extends true
 	? Packed<"UserDetailed">
diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts
index 6e448d4b17..4b4ea96270 100644
--- a/packages/backend/src/remote/activitypub/db-resolver.ts
+++ b/packages/backend/src/remote/activitypub/db-resolver.ts
@@ -20,8 +20,8 @@ import type { IObject } from "./type.js";
 import { getApId } from "./type.js";
 import { resolvePerson } from "./models/person.js";
 
-const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
-const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
+const publicKeyCache = new Cache<UserPublickey | null>("publicKey", Infinity);
+const publicKeyByUserIdCache = new Cache<UserPublickey | null>("publicKeyByUserId", Infinity);
 
 export type UriParseResult =
 	| {
diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts
index 42274ad2a4..b6fa973eb2 100644
--- a/packages/backend/src/server/api/authenticate.ts
+++ b/packages/backend/src/server/api/authenticate.ts
@@ -9,7 +9,7 @@ import {
 	localUserByNativeTokenCache,
 } from "@/services/user-cache.js";
 
-const appCache = new Cache<App>(Infinity);
+const appCache = new Cache<App>("app", Infinity);
 
 export class AuthenticationError extends Error {
 	constructor(message: string) {
diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts
index dbfb28ff6a..28cefd2cf7 100644
--- a/packages/backend/src/server/nodeinfo.ts
+++ b/packages/backend/src/server/nodeinfo.ts
@@ -100,7 +100,7 @@ const nodeinfo2 = async () => {
 	};
 };
 
-const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
+const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>("nodeinfo", 1000 * 60 * 10);
 
 router.get(nodeinfo2_1path, async (ctx) => {
 	const base = await cache.fetch(null, () => nodeinfo2());
diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts
index 9240f31073..1822cb3c7c 100644
--- a/packages/backend/src/services/instance-actor.ts
+++ b/packages/backend/src/services/instance-actor.ts
@@ -6,7 +6,7 @@ import { IsNull } from "typeorm";
 
 const ACTOR_USERNAME = "instance.actor" as const;
 
-const cache = new Cache<ILocalUser>(Infinity);
+const cache = new Cache<ILocalUser>("instanceActor", Infinity);
 
 export async function getInstanceActor(): Promise<ILocalUser> {
 	const cached = await cache.get(null);
diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index f00678ce22..ca0f05f2c3 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -73,7 +73,7 @@ import { Mutex } from "redis-semaphore";
 
 const mutedWordsCache = new Cache<
 	{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
->(1000 * 60 * 5);
+>("mutedWords", 1000 * 60 * 5);
 
 type NotificationType = "reply" | "renote" | "quote" | "mention";
 
diff --git a/packages/backend/src/services/register-or-fetch-instance-doc.ts b/packages/backend/src/services/register-or-fetch-instance-doc.ts
index ddb9ce2413..e0c2880376 100644
--- a/packages/backend/src/services/register-or-fetch-instance-doc.ts
+++ b/packages/backend/src/services/register-or-fetch-instance-doc.ts
@@ -4,7 +4,7 @@ import { genId } from "@/misc/gen-id.js";
 import { toPuny } from "@/misc/convert-host.js";
 import { Cache } from "@/misc/cache.js";
 
-const cache = new Cache<Instance>(1000 * 60 * 60);
+const cache = new Cache<Instance>("registerOrFetchInstanceDoc", 1000 * 60 * 60);
 
 export async function registerOrFetchInstanceDoc(
 	host: string,
diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts
index 2325f76c69..1ec2891e8a 100644
--- a/packages/backend/src/services/relay.ts
+++ b/packages/backend/src/services/relay.ts
@@ -15,7 +15,7 @@ import { createSystemUser } from "./create-system-user.js";
 
 const ACTOR_USERNAME = "relay.actor" as const;
 
-const relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
+const relaysCache = new Cache<Relay[]>("relay", 1000 * 60 * 10);
 
 export async function getRelayActor(): Promise<ILocalUser> {
 	const user = await Users.findOneBy({
diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts
index 373fb86869..7fde21d873 100644
--- a/packages/backend/src/services/user-cache.ts
+++ b/packages/backend/src/services/user-cache.ts
@@ -3,17 +3,17 @@ import type {
 	CacheableUser,
 	ILocalUser,
 } from "@/models/entities/user.js";
-import { User } from "@/models/entities/user.js";
 import { Users } from "@/models/index.js";
 import { Cache } from "@/misc/cache.js";
 import { redisClient, subscriber } from "@/db/redis.js";
 
-export const userByIdCache = new Cache<CacheableUser>(Infinity);
+export const userByIdCache = new Cache<CacheableUser>("userById", Infinity);
 export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(
+	"localUserByNativeToken",
 	Infinity,
 );
-export const localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
-export const uriPersonCache = new Cache<CacheableUser | null>(Infinity);
+export const localUserByIdCache = new Cache<CacheableLocalUser>("localUserByIdCache", Infinity);
+export const uriPersonCache = new Cache<CacheableUser | null>("uriPerson", Infinity);
 
 subscriber.on("message", async (_, data) => {
 	const obj = JSON.parse(data);

From 355b1e0063ab7dea54ce1388f0eb3ad01137180b Mon Sep 17 00:00:00 2001
From: Namekuji <nmkj@waah.day>
Date: Sun, 2 Jul 2023 22:10:33 -0400
Subject: [PATCH 03/11] no more infinity caches

---
 packages/backend/src/misc/cache.ts            | 36 ++++++++++++------
 .../backend/src/misc/check-hit-antenna.ts     |  2 +-
 packages/backend/src/misc/emoji-meta.ts       |  2 +-
 packages/backend/src/misc/keypair-store.ts    |  8 ++--
 packages/backend/src/misc/populate-emojis.ts  |  2 +-
 .../backend/src/models/repositories/user.ts   |  6 ++-
 .../src/remote/activitypub/db-resolver.ts     | 38 ++++++++++++-------
 .../src/remote/activitypub/models/person.ts   |  2 +-
 .../backend/src/server/api/authenticate.ts    | 10 +++--
 packages/backend/src/server/nodeinfo.ts       |  5 ++-
 .../backend/src/services/instance-actor.ts    |  4 +-
 packages/backend/src/services/note/create.ts  |  7 +---
 .../register-or-fetch-instance-doc.ts         |  2 +-
 packages/backend/src/services/relay.ts        |  2 +-
 packages/backend/src/services/user-cache.ts   | 14 +++++--
 15 files changed, 89 insertions(+), 51 deletions(-)

diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index c2695aa789..588931ff1f 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -6,8 +6,8 @@ export class Cache<T> {
 	private ttl: number;
 	private prefix: string;
 
-	constructor(prefix: string, ttl: number) {
-		this.ttl = ttl;
+	constructor(prefix: string, ttlSeconds: number) {
+		this.ttl = ttlSeconds;
 		this.prefix = `cache:${prefix}`;
 	}
 
@@ -15,26 +15,28 @@ export class Cache<T> {
 		return key ? `${this.prefix}:${key}` : this.prefix;
 	}
 
-	public async set(key: string | null, value: T, transaction?: ChainableCommander): Promise<void> {
+	public async set(
+		key: string | null,
+		value: T,
+		transaction?: ChainableCommander,
+	): Promise<void> {
 		const _key = this.prefixedKey(key);
 		const _value = Buffer.from(encode(value));
 		const commander = transaction ?? redisClient;
-		if (this.ttl === Infinity) {
-			await commander.set(_key, _value);
-		} else {
-			await commander.set(_key, _value, "PX", this.ttl);
-		}
+		await commander.set(_key, _value, "EX", this.ttl);
 	}
 
-	public async get(key: string | null): Promise<T | undefined> {
+	public async get(key: string | null, renew = false): Promise<T | undefined> {
 		const _key = this.prefixedKey(key);
 		const cached = await redisClient.getBuffer(_key);
 		if (cached === null) return undefined;
 
+		if (renew) await redisClient.expire(_key, this.ttl);
+
 		return decode(cached) as T;
 	}
 
-	public async getAll(): Promise<Map<string, T>> {
+	public async getAll(renew = false): Promise<Map<string, T>> {
 		const keys = await redisClient.keys(`${this.prefix}*`);
 		const map = new Map<string, T>();
 		if (keys.length === 0) {
@@ -49,6 +51,14 @@ export class Cache<T> {
 			}
 		}
 
+		if (renew) {
+			const trans = redisClient.multi();
+			for (const key of map.keys()) {
+				trans.expire(key, this.ttl);
+			}
+			await trans.exec();
+		}
+
 		return map;
 	}
 
@@ -66,9 +76,10 @@ export class Cache<T> {
 	public async fetch(
 		key: string | null,
 		fetcher: () => Promise<T>,
+		renew = false,
 		validator?: (cachedValue: T) => boolean,
 	): Promise<T> {
-		const cachedValue = await this.get(key);
+		const cachedValue = await this.get(key, renew);
 		if (cachedValue !== undefined) {
 			if (validator) {
 				if (validator(cachedValue)) {
@@ -94,9 +105,10 @@ export class Cache<T> {
 	public async fetchMaybe(
 		key: string | null,
 		fetcher: () => Promise<T | undefined>,
+		renew = false,
 		validator?: (cachedValue: T) => boolean,
 	): Promise<T | undefined> {
-		const cachedValue = await this.get(key);
+		const cachedValue = await this.get(key, renew);
 		if (cachedValue !== undefined) {
 			if (validator) {
 				if (validator(cachedValue)) {
diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts
index c422cca943..1ff09d6299 100644
--- a/packages/backend/src/misc/check-hit-antenna.ts
+++ b/packages/backend/src/misc/check-hit-antenna.ts
@@ -11,7 +11,7 @@ import * as Acct from "@/misc/acct.js";
 import type { Packed } from "./schema.js";
 import { Cache } from "./cache.js";
 
-const blockingCache = new Cache<User["id"][]>("blocking", 1000 * 60 * 5);
+const blockingCache = new Cache<User["id"][]>("blocking", 60 * 5);
 
 // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
 
diff --git a/packages/backend/src/misc/emoji-meta.ts b/packages/backend/src/misc/emoji-meta.ts
index d2d15411f7..2b9365b826 100644
--- a/packages/backend/src/misc/emoji-meta.ts
+++ b/packages/backend/src/misc/emoji-meta.ts
@@ -11,7 +11,7 @@ export type Size = {
 	height: number;
 };
 
-const cache = new Cache<boolean>("emojiMeta",1000 * 60 * 10); // once every 10 minutes for the same url
+const cache = new Cache<boolean>("emojiMeta", 60 * 10); // once every 10 minutes for the same url
 const logger = new Logger("emoji");
 
 export async function getEmojiSize(url: string): Promise<Size> {
diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts
index b0e07c4ab4..6255773599 100644
--- a/packages/backend/src/misc/keypair-store.ts
+++ b/packages/backend/src/misc/keypair-store.ts
@@ -3,10 +3,12 @@ import type { User } from "@/models/entities/user.js";
 import type { UserKeypair } from "@/models/entities/user-keypair.js";
 import { Cache } from "./cache.js";
 
-const cache = new Cache<UserKeypair>("keypairStore", Infinity);
+const cache = new Cache<UserKeypair>("keypairStore", 60 * 30);
 
 export async function getUserKeypair(userId: User["id"]): Promise<UserKeypair> {
-	return await cache.fetch(userId, () =>
-		UserKeypairs.findOneByOrFail({ userId: userId }),
+	return await cache.fetch(
+		userId,
+		() => UserKeypairs.findOneByOrFail({ userId: userId }),
+		true,
 	);
 }
diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts
index e6e6c2fb90..795a267f91 100644
--- a/packages/backend/src/misc/populate-emojis.ts
+++ b/packages/backend/src/misc/populate-emojis.ts
@@ -9,7 +9,7 @@ import config from "@/config/index.js";
 import { query } from "@/prelude/url.js";
 import { redisClient } from "@/db/redis.js";
 
-const cache = new Cache<Emoji | null>("populateEmojis", 1000 * 60 * 60 * 12);
+const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
 
 /**
  * 添付用絵文字情報
diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts
index 2bd8d4fbaa..5ca36e3d31 100644
--- a/packages/backend/src/models/repositories/user.ts
+++ b/packages/backend/src/models/repositories/user.ts
@@ -1,4 +1,3 @@
-import { URL } from "url";
 import { In, Not } from "typeorm";
 import Ajv from "ajv";
 import type { ILocalUser, IRemoteUser } from "@/models/entities/user.js";
@@ -40,7 +39,10 @@ import {
 } from "../index.js";
 import type { Instance } from "../entities/instance.js";
 
-const userInstanceCache = new Cache<Instance | null>("userInstance", 1000 * 60 * 60 * 3);
+const userInstanceCache = new Cache<Instance | null>(
+	"userInstance",
+	60 * 60 * 3,
+);
 
 type IsUserDetailed<Detailed extends boolean> = Detailed extends true
 	? Packed<"UserDetailed">
diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts
index 4b4ea96270..a710b9f115 100644
--- a/packages/backend/src/remote/activitypub/db-resolver.ts
+++ b/packages/backend/src/remote/activitypub/db-resolver.ts
@@ -5,7 +5,6 @@ import type {
 	CacheableRemoteUser,
 	CacheableUser,
 } from "@/models/entities/user.js";
-import { User, IRemoteUser } from "@/models/entities/user.js";
 import type { UserPublickey } from "@/models/entities/user-publickey.js";
 import type { MessagingMessage } from "@/models/entities/messaging-message.js";
 import {
@@ -20,8 +19,11 @@ import type { IObject } from "./type.js";
 import { getApId } from "./type.js";
 import { resolvePerson } from "./models/person.js";
 
-const publicKeyCache = new Cache<UserPublickey | null>("publicKey", Infinity);
-const publicKeyByUserIdCache = new Cache<UserPublickey | null>("publicKeyByUserId", Infinity);
+const publicKeyCache = new Cache<UserPublickey | null>("publicKey", 60 * 30);
+const publicKeyByUserIdCache = new Cache<UserPublickey | null>(
+	"publicKeyByUserId",
+	60 * 30,
+);
 
 export type UriParseResult =
 	| {
@@ -123,17 +125,23 @@ export default class DbResolver {
 			if (parsed.type !== "users") return null;
 
 			return (
-				(await userByIdCache.fetchMaybe(parsed.id, () =>
-					Users.findOneBy({
-						id: parsed.id,
-					}).then((x) => x ?? undefined),
+				(await userByIdCache.fetchMaybe(
+					parsed.id,
+					() =>
+						Users.findOneBy({
+							id: parsed.id,
+						}).then((x) => x ?? undefined),
+					true,
 				)) ?? null
 			);
 		} else {
-			return await uriPersonCache.fetch(parsed.uri, () =>
-				Users.findOneBy({
-					uri: parsed.uri,
-				}),
+			return await uriPersonCache.fetch(
+				parsed.uri,
+				() =>
+					Users.findOneBy({
+						uri: parsed.uri,
+					}),
+				true,
 			);
 		}
 	}
@@ -156,14 +164,17 @@ export default class DbResolver {
 
 				return key;
 			},
+			true,
 			(key) => key != null,
 		);
 
 		if (key == null) return null;
 
 		return {
-			user: (await userByIdCache.fetch(key.userId, () =>
-				Users.findOneByOrFail({ id: key.userId }),
+			user: (await userByIdCache.fetch(
+				key.userId,
+				() => Users.findOneByOrFail({ id: key.userId }),
+				true,
 			)) as CacheableRemoteUser,
 			key,
 		};
@@ -183,6 +194,7 @@ export default class DbResolver {
 		const key = await publicKeyByUserIdCache.fetch(
 			user.id,
 			() => UserPublickeys.findOneBy({ userId: user.id }),
+			true,
 			(v) => v != null,
 		);
 
diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts
index c541e9ae50..c5519ba031 100644
--- a/packages/backend/src/remote/activitypub/models/person.ts
+++ b/packages/backend/src/remote/activitypub/models/person.ts
@@ -135,7 +135,7 @@ export async function fetchPerson(
 ): Promise<CacheableUser | null> {
 	if (typeof uri !== "string") throw new Error("uri is not string");
 
-	const cached = await uriPersonCache.get(uri);
+	const cached = await uriPersonCache.get(uri, true);
 	if (cached) return cached;
 
 	// Fetch from the database if the URI points to this server
diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts
index b6fa973eb2..460a0ce84b 100644
--- a/packages/backend/src/server/api/authenticate.ts
+++ b/packages/backend/src/server/api/authenticate.ts
@@ -9,7 +9,7 @@ import {
 	localUserByNativeTokenCache,
 } from "@/services/user-cache.js";
 
-const appCache = new Cache<App>("app", Infinity);
+const appCache = new Cache<App>("app", 60 * 30);
 
 export class AuthenticationError extends Error {
 	constructor(message: string) {
@@ -49,6 +49,7 @@ export default async (
 		const user = await localUserByNativeTokenCache.fetch(
 			token,
 			() => Users.findOneBy({ token }) as Promise<ILocalUser | null>,
+			true,
 		);
 
 		if (user == null) {
@@ -82,11 +83,14 @@ export default async (
 				Users.findOneBy({
 					id: accessToken.userId,
 				}) as Promise<ILocalUser>,
+			true,
 		);
 
 		if (accessToken.appId) {
-			const app = await appCache.fetch(accessToken.appId, () =>
-				Apps.findOneByOrFail({ id: accessToken.appId! }),
+			const app = await appCache.fetch(
+				accessToken.appId,
+				() => Apps.findOneByOrFail({ id: accessToken.appId! }),
+				true,
 			);
 
 			return [
diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts
index 28cefd2cf7..940ca2e135 100644
--- a/packages/backend/src/server/nodeinfo.ts
+++ b/packages/backend/src/server/nodeinfo.ts
@@ -100,7 +100,10 @@ const nodeinfo2 = async () => {
 	};
 };
 
-const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>("nodeinfo", 1000 * 60 * 10);
+const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(
+	"nodeinfo",
+	60 * 10,
+);
 
 router.get(nodeinfo2_1path, async (ctx) => {
 	const base = await cache.fetch(null, () => nodeinfo2());
diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts
index 1822cb3c7c..a8b34ea57b 100644
--- a/packages/backend/src/services/instance-actor.ts
+++ b/packages/backend/src/services/instance-actor.ts
@@ -6,10 +6,10 @@ import { IsNull } from "typeorm";
 
 const ACTOR_USERNAME = "instance.actor" as const;
 
-const cache = new Cache<ILocalUser>("instanceActor", Infinity);
+const cache = new Cache<ILocalUser>("instanceActor", 60 * 30);
 
 export async function getInstanceActor(): Promise<ILocalUser> {
-	const cached = await cache.get(null);
+	const cached = await cache.get(null, true);
 	if (cached) return cached;
 
 	const user = (await Users.findOneBy({
diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index ca0f05f2c3..095c75f427 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -29,17 +29,14 @@ import {
 	Notes,
 	Instances,
 	UserProfiles,
-	Antennas,
-	Followings,
 	MutedNotes,
 	Channels,
 	ChannelFollowings,
-	Blockings,
 	NoteThreadMutings,
 } from "@/models/index.js";
 import type { DriveFile } from "@/models/entities/drive-file.js";
 import type { App } from "@/models/entities/app.js";
-import { Not, In, IsNull } from "typeorm";
+import { Not, In } from "typeorm";
 import type { User, ILocalUser, IRemoteUser } from "@/models/entities/user.js";
 import { genId } from "@/misc/gen-id.js";
 import {
@@ -73,7 +70,7 @@ import { Mutex } from "redis-semaphore";
 
 const mutedWordsCache = new Cache<
 	{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
->("mutedWords", 1000 * 60 * 5);
+>("mutedWords", 60 * 5);
 
 type NotificationType = "reply" | "renote" | "quote" | "mention";
 
diff --git a/packages/backend/src/services/register-or-fetch-instance-doc.ts b/packages/backend/src/services/register-or-fetch-instance-doc.ts
index e0c2880376..c0ead08190 100644
--- a/packages/backend/src/services/register-or-fetch-instance-doc.ts
+++ b/packages/backend/src/services/register-or-fetch-instance-doc.ts
@@ -4,7 +4,7 @@ import { genId } from "@/misc/gen-id.js";
 import { toPuny } from "@/misc/convert-host.js";
 import { Cache } from "@/misc/cache.js";
 
-const cache = new Cache<Instance>("registerOrFetchInstanceDoc", 1000 * 60 * 60);
+const cache = new Cache<Instance>("registerOrFetchInstanceDoc", 60 * 60);
 
 export async function registerOrFetchInstanceDoc(
 	host: string,
diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts
index 1ec2891e8a..6f7829c218 100644
--- a/packages/backend/src/services/relay.ts
+++ b/packages/backend/src/services/relay.ts
@@ -15,7 +15,7 @@ import { createSystemUser } from "./create-system-user.js";
 
 const ACTOR_USERNAME = "relay.actor" as const;
 
-const relaysCache = new Cache<Relay[]>("relay", 1000 * 60 * 10);
+const relaysCache = new Cache<Relay[]>("relay", 60 * 10);
 
 export async function getRelayActor(): Promise<ILocalUser> {
 	const user = await Users.findOneBy({
diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts
index 7fde21d873..ed700185df 100644
--- a/packages/backend/src/services/user-cache.ts
+++ b/packages/backend/src/services/user-cache.ts
@@ -7,13 +7,19 @@ import { Users } from "@/models/index.js";
 import { Cache } from "@/misc/cache.js";
 import { redisClient, subscriber } from "@/db/redis.js";
 
-export const userByIdCache = new Cache<CacheableUser>("userById", Infinity);
+export const userByIdCache = new Cache<CacheableUser>("userById", 60 * 30);
 export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(
 	"localUserByNativeToken",
-	Infinity,
+	60 * 30,
+);
+export const localUserByIdCache = new Cache<CacheableLocalUser>(
+	"localUserByIdCache",
+	60 * 30,
+);
+export const uriPersonCache = new Cache<CacheableUser | null>(
+	"uriPerson",
+	60 * 30,
 );
-export const localUserByIdCache = new Cache<CacheableLocalUser>("localUserByIdCache", Infinity);
-export const uriPersonCache = new Cache<CacheableUser | null>("uriPerson", Infinity);
 
 subscriber.on("message", async (_, data) => {
 	const obj = JSON.parse(data);

From f17c9837c572095a27f6ea9a83e1aadab15b6bb7 Mon Sep 17 00:00:00 2001
From: Syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 2 Jul 2023 16:20:40 -0700
Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20=E2=9A=A1=20make=20identicons?=
 =?UTF-8?q?=20and=20server=20metrics=20optional?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Kainoa Kanter <kainoa@t1c.dev>
---
 locales/en-US.yml                             |   2 ++
 locales/ja-JP.yml                             |   2 ++
 packages/backend/assets/avatar.png            | Bin 0 -> 14059 bytes
 .../1688280713783-add-meta-options.js         |  21 ++++++++++++++++++
 packages/backend/src/daemons/server-stats.ts  |   4 ++++
 packages/backend/src/models/entities/meta.ts  |  10 +++++++++
 .../src/server/api/endpoints/admin/meta.ts    |  12 ++++++++++
 .../src/server/api/endpoints/server-info.ts   |  21 +++++++++++++++++-
 packages/backend/src/server/index.ts          |  15 +++++++++----
 packages/client/src/pages/admin/settings.vue  |  17 ++++++++++++++
 .../src/widgets/server-metric/index.vue       |   2 +-
 11 files changed, 100 insertions(+), 6 deletions(-)
 create mode 100644 packages/backend/assets/avatar.png
 create mode 100644 packages/backend/migration/1688280713783-add-meta-options.js

diff --git a/locales/en-US.yml b/locales/en-US.yml
index d493f332f5..c6c442df7c 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1112,6 +1112,8 @@ isModerator: "Moderator"
 isAdmin: "Administrator"
 isPatron: "Calckey Patron"
 reactionPickerSkinTone: "Preferred emoji skin tone"
+enableServerMachineStats: "Enable server hardware statistics"
+enableIdenticonGeneration: "Enable Identicon generation"
 
 _sensitiveMediaDetection:
   description: "Reduces the effort of server moderation through automatically recognizing
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index fa3c3f1cc1..9f2f825e55 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -978,6 +978,8 @@ enableCustomKaTeXMacro: "カスタムKaTeXマクロを有効にする"
 preventAiLearning: "AIによる学習を防止"
 preventAiLearningDescription: "投稿したノート、添付した画像などのコンテンツを学習の対象にしないようAIに要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されます。"
 noGraze: "ブラウザの拡張機能「Graze for Mastodon」は、Calckeyの動作を妨げるため、無効にしてください。"
+enableServerMachineStats: "サーバーのマシン情報を公開する"
+enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
 
 _sensitiveMediaDetection:
   description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"
diff --git a/packages/backend/assets/avatar.png b/packages/backend/assets/avatar.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee22bdb3c8e56d6e44c1a8eb3dec0d62bb427657
GIT binary patch
literal 14059
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
NDa&ifRmoU}{|~xPiZcKJ

literal 0
HcmV?d00001

diff --git a/packages/backend/migration/1688280713783-add-meta-options.js b/packages/backend/migration/1688280713783-add-meta-options.js
new file mode 100644
index 0000000000..e97a95c423
--- /dev/null
+++ b/packages/backend/migration/1688280713783-add-meta-options.js
@@ -0,0 +1,21 @@
+export class AddMetaOptions1688280713783 {
+	name = "AddMetaOptions1688280713783";
+
+	async up(queryRunner) {
+		await queryRunner.query(
+			`ALTER TABLE "meta" ADD "enableServerMachineStats" boolean NOT NULL DEFAULT false`,
+		);
+		await queryRunner.query(
+			`ALTER TABLE "meta" ADD "enableIdenticonGeneration" boolean NOT NULL DEFAULT true`,
+		);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(
+			`ALTER TABLE "meta" DROP COLUMN "enableIdenticonGeneration"`,
+		);
+		await queryRunner.query(
+			`ALTER TABLE "meta" DROP COLUMN "enableServerMachineStats"`,
+		);
+	}
+}
diff --git a/packages/backend/src/daemons/server-stats.ts b/packages/backend/src/daemons/server-stats.ts
index c936d619ab..ba74278762 100644
--- a/packages/backend/src/daemons/server-stats.ts
+++ b/packages/backend/src/daemons/server-stats.ts
@@ -1,6 +1,7 @@
 import si from "systeminformation";
 import Xev from "xev";
 import * as osUtils from "os-utils";
+import { fetchMeta } from "@/misc/fetch-meta.js";
 import meilisearch from "../db/meilisearch.js";
 
 const ev = new Xev();
@@ -20,6 +21,9 @@ export default function () {
 		ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length || 50));
 	});
 
+	const meta = fetchMeta();
+	if (!meta.enableServerMachineStats) return;
+
 	async function tick() {
 		const cpu = await cpuUsage();
 		const memStats = await mem();
diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts
index dd3c5b3b72..200ef50552 100644
--- a/packages/backend/src/models/entities/meta.ts
+++ b/packages/backend/src/models/entities/meta.ts
@@ -546,4 +546,14 @@ export class Meta {
 		default: {},
 	})
 	public experimentalFeatures: Record<string, unknown>;
+
+	@Column("boolean", {
+		default: false,
+	})
+	public enableServerMachineStats: boolean;
+
+	@Column("boolean", {
+		default: true,
+	})
+	public enableIdenticonGeneration: boolean;
 }
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 3193301275..50317d4a81 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -481,6 +481,16 @@ export const meta = {
 					},
 				},
 			},
+			enableServerMachineStats: {
+				type: "boolean",
+				optional: false,
+				nullable: false,
+			},
+			enableIdenticonGeneration: {
+				type: "boolean",
+				optional: false,
+				nullable: false,
+			},
 		},
 	},
 } as const;
@@ -592,5 +602,7 @@ export default define(meta, paramDef, async (ps, me) => {
 		enableIpLogging: instance.enableIpLogging,
 		enableActiveEmailValidation: instance.enableActiveEmailValidation,
 		experimentalFeatures: instance.experimentalFeatures,
+		enableServerMachineStats: instance.enableServerMachineStats,
+		enableIdenticonGeneration: instance.enableIdenticonGeneration,
 	};
 });
diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts
index 81bb053db4..746eae6621 100644
--- a/packages/backend/src/server/api/endpoints/server-info.ts
+++ b/packages/backend/src/server/api/endpoints/server-info.ts
@@ -2,11 +2,13 @@ import * as os from "node:os";
 import si from "systeminformation";
 import define from "../define.js";
 import meilisearch from "@/db/meilisearch.js";
+import { fetchMeta } from "@/misc/fetch-meta.js";
 
 export const meta = {
 	requireCredential: false,
 	requireCredentialPrivateMode: true,
-
+	allowGet: true,
+	cacheSec: 30,
 	tags: ["meta"],
 } as const;
 
@@ -29,6 +31,23 @@ export default define(meta, paramDef, async () => {
 		}
 	}
 
+	const instanceMeta = await fetchMeta();
+	if (!instanceMeta.enableServerMachineStats) {
+		return {
+			machine: 'Not specified',
+			cpu: {
+				model: 'Not specified',
+				cores: 0,
+			},
+			mem: {
+				total: 0,
+			},
+			fs: {
+				total: 0,
+				used: 0,
+			},
+		};
+	}
 	return {
 		machine: os.hostname(),
 		cpu: {
diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts
index 95d570eb3d..7f8d0ed718 100644
--- a/packages/backend/src/server/index.ts
+++ b/packages/backend/src/server/index.ts
@@ -16,6 +16,7 @@ import { IsNull } from "typeorm";
 import config from "@/config/index.js";
 import Logger from "@/services/logger.js";
 import { UserProfiles, Users } from "@/models/index.js";
+import { fetchMeta } from "@/misc/fetch-meta.js";
 import { genIdenticon } from "@/misc/gen-identicon.js";
 import { createTemp } from "@/misc/create-temp.js";
 import { publishMainStream } from "@/services/stream.js";
@@ -125,10 +126,16 @@ router.get("/avatar/@:acct", async (ctx) => {
 });
 
 router.get("/identicon/:x", async (ctx) => {
-	const [temp, cleanup] = await createTemp();
-	await genIdenticon(ctx.params.x, fs.createWriteStream(temp));
-	ctx.set("Content-Type", "image/png");
-	ctx.body = fs.createReadStream(temp).on("close", () => cleanup());
+	const meta = await fetchMeta();
+	if (meta.enableIdenticonGeneration) {
+		const [temp, cleanup] = await createTemp();
+		await genIdenticon(ctx.params.x, fs.createWriteStream(temp));
+		ctx.set("Content-Type", "image/png");
+		ctx.body = fs.createReadStream(temp).on("close", () => cleanup());
+	}
+	else {
+		ctx.redirect("/static-assets/avatar.png")
+	}
 });
 
 mastoRouter.get("/oauth/authorize", async (ctx) => {
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index e67c44b0a4..0e83250bac 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -343,6 +343,17 @@
 							</template>
 						</FormSection>
 
+						<FormSection>
+							<template #label>Server Performance</template>
+							<FormSwitch v-model="enableServerMachineStats">
+								<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
+							</FormSwitch>
+
+							<FormSwitch v-model="enableIdenticonGeneration">
+								<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
+							</FormSwitch>
+						</FormSection>
+
 						<FormSection>
 							<template #label>DeepL Translation</template>
 
@@ -442,6 +453,8 @@ let libreTranslateApiUrl: string = $ref("");
 let libreTranslateApiKey: string = $ref("");
 let defaultReaction: string = $ref("");
 let defaultReactionCustom: string = $ref("");
+let enableServerMachineStats: boolean = $ref(false);
+let enableIdenticonGeneration: boolean = $ref(false);
 
 async function init() {
 	const meta = await os.api("admin/meta");
@@ -482,6 +495,8 @@ async function init() {
 	defaultReactionCustom = ["⭐", "👍", "❤️"].includes(meta.defaultReaction)
 		? ""
 		: meta.defaultReaction;
+	enableServerMachineStats = meta.enableServerMachineStats;
+	enableIdenticonGeneration = meta.enableIdenticonGeneration;
 }
 
 function save() {
@@ -521,6 +536,8 @@ function save() {
 		libreTranslateApiUrl,
 		libreTranslateApiKey,
 		defaultReaction,
+		enableServerMachineStats,
+		enableIdenticonGeneration,
 	}).then(() => {
 		fetchInstance();
 	});
diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue
index cf84212b1e..49dc59ea27 100644
--- a/packages/client/src/widgets/server-metric/index.vue
+++ b/packages/client/src/widgets/server-metric/index.vue
@@ -106,7 +106,7 @@ const { widgetProps, configure, save } = useWidgetPropsManager(
 
 const meta = ref(null);
 
-os.api("server-info", {}).then((res) => {
+os.apiGet("server-info", {}).then((res) => {
 	meta.value = res;
 });
 

From af4797bb8e9d69f3452f5d19c92220f700b70e9a Mon Sep 17 00:00:00 2001
From: Namekuji <nmkj@waah.day>
Date: Sun, 2 Jul 2023 22:21:19 -0400
Subject: [PATCH 05/11] throw error if failed

---
 .../backend/src/server/api/endpoints/admin/emoji/add.ts  | 9 ++-------
 .../backend/src/server/api/endpoints/admin/emoji/copy.ts | 9 ++-------
 2 files changed, 4 insertions(+), 14 deletions(-)

diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 7d40816135..4366406ec3 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -6,7 +6,7 @@ import { ApiError } from "../../../error.js";
 import rndstr from "rndstr";
 import { publishBroadcastStream } from "@/services/stream.js";
 import { db } from "@/db/postgre.js";
-import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
+import { getEmojiSize } from "@/misc/emoji-meta.js";
 
 export const meta = {
 	tags: ["admin"],
@@ -40,12 +40,7 @@ export default define(meta, paramDef, async (ps, me) => {
 		? file.name.split(".")[0]
 		: `_${rndstr("a-z0-9", 8)}_`;
 
-	let size: Size = { width: 0, height: 0 };
-	try {
-		size = await getEmojiSize(file.url);
-	} catch {
-		/* skip if any error happens */
-	}
+	const size = await getEmojiSize(file.url);
 
 	const emoji = await Emojis.insert({
 		id: genId(),
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
index 45cb9464db..c90e606335 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
@@ -6,7 +6,7 @@ import type { DriveFile } from "@/models/entities/drive-file.js";
 import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
 import { publishBroadcastStream } from "@/services/stream.js";
 import { db } from "@/db/postgre.js";
-import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
+import { getEmojiSize } from "@/misc/emoji-meta.js";
 
 export const meta = {
 	tags: ["admin"],
@@ -65,12 +65,7 @@ export default define(meta, paramDef, async (ps, me) => {
 		throw new ApiError();
 	}
 
-	let size: Size = { width: 0, height: 0 };
-	try {
-		size = await getEmojiSize(driveFile.url);
-	} catch {
-		/* skip if any error happens */
-	}
+	const size = await getEmojiSize(driveFile.url);
 
 	const copied = await Emojis.insert({
 		id: genId(),

From 3434c23ae6a63b9bf472dbb55dd7ec59a13ffefd Mon Sep 17 00:00:00 2001
From: Poesty Li <poesty7450@gmail.com>
Date: Mon, 3 Jul 2023 01:25:50 +0000
Subject: [PATCH 06/11] chore: Translated using Weblate (Chinese (Simplified))

Currently translated at 97.4% (1769 of 1816 strings)

Translation: Calckey/locales
Translate-URL: https://hosted.weblate.org/projects/calckey/locales/zh_Hans/
---
 locales/zh-CN.yml | 42 ++++++++++++++++++++++--------------------
 1 file changed, 22 insertions(+), 20 deletions(-)

diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 6903f447d0..110149d737 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -10,7 +10,7 @@ password: "密码"
 forgotPassword: "忘记密码"
 fetchingAsApObject: "正在联邦宇宙查询中"
 ok: "OK"
-gotIt: "我明白了"
+gotIt: "知道了!"
 cancel: "取消"
 enterUsername: "输入用户名"
 renotedBy: "转发自 {user}"
@@ -78,7 +78,7 @@ followsYou: "正在关注你"
 createList: "创建列表"
 manageLists: "管理列表"
 error: "错误"
-somethingHappened: "出现了一些问题!"
+somethingHappened: "发生了一个错误"
 retry: "重试"
 pageLoadError: "页面加载失败。"
 pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。"
@@ -202,7 +202,7 @@ noUsers: "无用户"
 editProfile: "编辑资料"
 noteDeleteConfirm: "要删除该帖子吗?"
 pinLimitExceeded: "无法置顶更多帖子了"
-intro: "Misskey的部署结束啦!填写管理员账号吧!"
+intro: "Calckey安装完成!请创建一个管理员用户。"
 done: "完成"
 processing: "正在处理"
 preview: "预览"
@@ -222,10 +222,10 @@ instanceFollowers: "服务器的关注者"
 instanceUsers: "此服务器的用户"
 changePassword: "修改密码"
 security: "安全"
-retypedNotMatch: "两次输入不一致!"
+retypedNotMatch: "两次输入不匹配。"
 currentPassword: "现在的密码"
 newPassword: "新密码"
-newPasswordRetype: "重新输入密码:"
+newPasswordRetype: "重新输入新密码"
 attachFile: "插入附件"
 more: "更多!"
 featured: "热门"
@@ -391,7 +391,7 @@ nUsersMentioned: "{n} 被提到"
 securityKey: "安全密钥"
 securityKeyName: "密钥名称"
 registerSecurityKey: "注册硬件安全密钥"
-lastUsed: "最后使用:"
+lastUsed: "上次使用"
 unregister: "删除账户"
 passwordLessLogin: "无密码登录"
 resetPassword: "重置密码"
@@ -639,7 +639,7 @@ openInNewTab: "在新标签页中打开"
 openInSideView: "在侧边栏中打开"
 defaultNavigationBehaviour: "默认导航"
 editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号"
-instanceTicker: "帖子的实例信息"
+instanceTicker: "帖子的服务器信息"
 waitingFor: "等待{x}"
 random: "随机"
 system: "系统"
@@ -759,7 +759,7 @@ instanceBlocking: "联邦管理"
 selectAccount: "选择账户"
 switchAccount: "切换账户"
 enabled: "已启用"
-disabled: "已禁用 "
+disabled: "已禁用"
 quickAction: "快捷操作"
 user: "用户"
 administration: "管理"
@@ -875,7 +875,7 @@ statusbar: "状态栏"
 pleaseSelect: "请选择"
 reverse: "翻转"
 colored: "彩色"
-refreshInterval: "刷新间隔"
+refreshInterval: "更新间隔 "
 label: "标签"
 type: "类型"
 speed: "速度"
@@ -1220,17 +1220,17 @@ _tutorial:
   step3_2: "你的主页和社交馈送是基于你所关注的人,所以试着先关注几个账户。{n点击个人资料右上角的加号圈就可以关注它。"
   step4_1: "让我们出去找你。"
   step4_2: "对于他们的第一条信息,有些人喜欢做{introduction}或一个简单的 \"hello world!\""
-  step5_1: "时间限制,到处是时间限制!"
-  step5_2: "您的实例已启用各种时间线的{timelines}。"
-  step5_3: "主{icon}时间线是你可以看到你的订阅者的帖子的时间线。"
-  step5_4: "本地{icon}时间线是你可以看到实例中所有其他用户的信息的时间线。"
-  step5_5: "推荐的{icon}时间线 - 是时间轴,你可以看到管理员推荐的实例的信息"
-  step5_6: "社交{icon}时间线显示来自你的订阅者朋友的信息。"
-  step5_7: "全球{icon}时间线是你可以看到来自所有其他连接的实例的消息。"
+  step5_1: "时间线,无处不在的时间线!"
+  step5_2: "您的服务器已启用{timelines}种不同的时间线。"
+  step5_3: "主页{icon}时间线是你可以看到你关注账户的帖子的时间线。"
+  step5_4: "本地{icon}时间线是你可以看到此服务器上其它用户的帖子的时间线。"
+  step5_5: "社交{icon}时间线是主页和本地时间线的结合。"
+  step5_6: "推荐{icon}时间线是你可以看到管理员推荐服务器的帖子的时间线。"
+  step5_7: "全球{icon}时间线是你可以看到来自其它所有互联服务器的帖子的时间线。"
   step6_1: "那么,这里是什么地方?"
-  step6_2: "好吧,你不只是加入卡尔基。你已经加入了Fediverse的一个门户,这是一个由成千上万台服务器组成的互联网络,被称为 \"实例\""
+  step6_2: "好吧,你不只是加入Calckey。你已经加入了Fediverse的一个门户,这是一个由成千上万台服务器组成的互联网络。"
   step6_3: "每个服务器的工作方式不同,并不是所有的服务器都运行Calckey。但这个人确实如此! 这有点复杂,但你很快就会明白的。"
-  step6_4: "现在去学习并享受乐趣!"
+  step6_4: "现在,去吧,去探索,去享受乐趣吧!"
 _2fa:
   alreadyRegistered: "此设备已被注册"
   registerTOTP: "注册设备"
@@ -1292,7 +1292,7 @@ _permissions:
 _auth:
   shareAccess: "您要授权允许“{name}”访问您的帐户吗?"
   shareAccessAsk: "您确定要授权此应用程序访问您的帐户吗?"
-  permissionAsk: "这个应用程序需要以下权限"
+  permissionAsk: "此应用程序请求以下权限:"
   pleaseGoBack: "请返回到应用程序"
   callback: "回到应用程序"
   denied: "拒绝访问"
@@ -1433,7 +1433,7 @@ _instanceCharts:
   usersTotal: "用户总计"
   notes: "帖子:增加/减少"
   notesTotal: "帖子总计"
-  ff: "关注/被关注:数量变化"
+  ff: "被关注用户/关注者的数量差异 "
   ffTotal: "关注/被关注者总计"
   cacheSize: "缓存大小:增加/减少"
   cacheSizeTotal: "缓存大小总计"
@@ -1939,3 +1939,5 @@ isPatron: Calckey 赞助
 _dialog:
   charactersExceeded: 超出了最大字符数!当前:{current} / 限制:{max}
   charactersBelow: 没有足够的字符!当前:{current} / 限制:{min}
+enableIdenticonGeneration: 启用Identicon生成
+enableServerMachineStats: 启用服务器硬件统计

From 722ccf04f9041cf9fcb68c6cc87411318ea0c8c4 Mon Sep 17 00:00:00 2001
From: Namekuji <nmkj@waah.day>
Date: Sun, 2 Jul 2023 23:14:28 -0400
Subject: [PATCH 07/11] rename arg

---
 packages/backend/src/misc/cache.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index 588931ff1f..fe68908e57 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -6,9 +6,9 @@ export class Cache<T> {
 	private ttl: number;
 	private prefix: string;
 
-	constructor(prefix: string, ttlSeconds: number) {
+	constructor(name: string, ttlSeconds: number) {
 		this.ttl = ttlSeconds;
-		this.prefix = `cache:${prefix}`;
+		this.prefix = `cache:${name}`;
 	}
 
 	private prefixedKey(key: string | null): string {

From ac51ce005156a73ff183080e29e17ae43802b004 Mon Sep 17 00:00:00 2001
From: freeplay <freeplay@duck.com>
Date: Sun, 2 Jul 2023 23:41:38 -0400
Subject: [PATCH 08/11] refactor: combine MediaVideo & MediaImage components

---
 .../{MkMediaImage.vue => MkMedia.vue}         |  96 ++++++++--
 .../client/src/components/MkMediaList.vue     |  26 +--
 .../client/src/components/MkMediaVideo.vue    | 169 ------------------
 3 files changed, 85 insertions(+), 206 deletions(-)
 rename packages/client/src/components/{MkMediaImage.vue => MkMedia.vue} (63%)
 delete mode 100644 packages/client/src/components/MkMediaVideo.vue

diff --git a/packages/client/src/components/MkMediaImage.vue b/packages/client/src/components/MkMedia.vue
similarity index 63%
rename from packages/client/src/components/MkMediaImage.vue
rename to packages/client/src/components/MkMedia.vue
index cbd5c0515e..36e52ef429 100644
--- a/packages/client/src/components/MkMediaImage.vue
+++ b/packages/client/src/components/MkMedia.vue
@@ -1,9 +1,9 @@
 <template>
 	<button v-if="hide" class="qjewsnkg" @click="hide = false">
 		<ImgWithBlurhash
-			:hash="image.blurhash"
-			:title="image.comment"
-			:alt="image.comment"
+			:hash="media.blurhash"
+			:title="media.comment"
+			:alt="media.comment"
 		/>
 		<div class="text">
 			<div class="wrapper">
@@ -15,20 +15,51 @@
 			</div>
 		</div>
 	</button>
-	<div v-else class="gqnyydlz">
-		<a :href="image.url">
+	<div v-else class="gqnyydlz media" :class="{ mini: plyrMini }">
+		<a 
+			v-if="media.type.startsWith('image')" 
+			:href="media.url"
+		>
 			<ImgWithBlurhash
-				:hash="image.blurhash"
+				:hash="media.blurhash"
 				:src="url"
-				:alt="image.comment"
-				:type="image.type"
+				:alt="media.comment"
+				:type="media.type"
 				:cover="false"
 			/>
-			<div v-if="image.type === 'image/gif'" class="gif">GIF</div>
+			<div v-if="media.type === 'image/gif'" class="gif">GIF</div>
 		</a>
+		<VuePlyr
+			v-if="media.type.startsWith('video')" 
+			ref="plyr"
+			:options="{
+				controls: [
+					'play-large',
+					'play',
+					'progress',
+					'current-time',
+					'mute',
+					'volume',
+					'pip',
+					'download',
+					'fullscreen',
+				],
+				disableContextMenu: false,
+			}"
+		>
+			<video
+				:poster="media.thumbnailUrl"
+				:aria-label="media.comment"
+				preload="none"
+				controls
+				@contextmenu.stop
+			>
+				<source :src="media.url" :type="media.type" />
+			</video>
+		</VuePlyr>
 		<div class="buttons">
 			<button
-				v-if="image.comment"
+				v-if="media.comment"
 				v-tooltip="i18n.ts.alt"
 				class="_button"
 				@click.stop="captionPopup"
@@ -47,7 +78,9 @@
 </template>
 
 <script lang="ts" setup>
-import { watch } from "vue";
+import { watch, ref, onMounted } from "vue";
+import VuePlyr from "vue-plyr";
+import "vue-plyr/dist/vue-plyr.css";
 import type * as misskey from "calckey-js";
 import { getStaticImageUrl } from "@/scripts/get-static-image-url";
 import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
@@ -56,34 +89,37 @@ import { i18n } from "@/i18n";
 import * as os from "@/os";
 
 const props = defineProps<{
-	image: misskey.entities.DriveFile;
+	media: misskey.entities.DriveFile;
 	raw?: boolean;
 }>();
 
 let hide = $ref(true);
 
+const plyr = ref();
+const plyrMini = ref(false);
+
 const url =
 	props.raw || defaultStore.state.loadRawImages
-		? props.image.url
+		? props.media.url
 		: defaultStore.state.disableShowingAnimatedImages
-		? getStaticImageUrl(props.image.thumbnailUrl)
-		: props.image.thumbnailUrl;
+		? getStaticImageUrl(props.media.thumbnailUrl)
+		: props.media.thumbnailUrl;
 
 function captionPopup() {
 	os.alert({
 		type: "info",
-		text: props.image.comment,
+		text: props.media.comment,
 	});
 }
 
 // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
 watch(
-	() => props.image,
+	() => props.media,
 	() => {
 		hide =
 			defaultStore.state.nsfw === "force"
 				? true
-				: props.image.isSensitive &&
+				: props.media.isSensitive &&
 				  defaultStore.state.nsfw !== "ignore";
 	},
 	{
@@ -91,6 +127,17 @@ watch(
 		immediate: true,
 	}
 );
+
+onMounted(() => {
+	if (props.media.type.startsWith('video')) {
+		plyrMini.value = plyr.value.player.media.scrollWidth < 300;
+		if (plyrMini.value) {
+			plyr.value.player.on("play", () => {
+				plyr.value.player.fullscreen.enter();
+			});
+		}
+	}
+});
 </script>
 
 <style lang="scss" scoped>
@@ -123,7 +170,7 @@ watch(
 	}
 }
 
-.gqnyydlz {
+.media {
 	position: relative;
 	background: var(--bg);
 
@@ -175,5 +222,16 @@ watch(
 			pointer-events: none;
 		}
 	}
+	&.mini {
+		:deep(.plyr:not(:fullscreen)) {
+			min-width: unset !important;
+			.plyr__control--overlaid,
+			.plyr__progress__container,
+			.plyr__volume,
+			[data-plyr="fullscreen"] {
+				display: none;
+			}
+		}
+	}
 }
 </style>
diff --git a/packages/client/src/components/MkMediaList.vue b/packages/client/src/components/MkMediaList.vue
index c01ccd5d81..83b4122789 100644
--- a/packages/client/src/components/MkMediaList.vue
+++ b/packages/client/src/components/MkMediaList.vue
@@ -12,25 +12,16 @@
 			:class="{ dmWidth: inDm }"
 		>
 			<div ref="gallery" @click.stop>
-				<template
+				<XMedia
 					v-for="media in mediaList.filter((media) =>
 						previewable(media)
 					)"
-				>
-					<XVideo
-						v-if="media.type.startsWith('video')"
-						:key="media.id"
-						:video="media"
-					/>
-					<XImage
-						v-else-if="media.type.startsWith('image')"
-						:key="media.id"
-						class="image"
-						:data-id="media.id"
-						:image="media"
-						:raw="raw"
-					/>
-				</template>
+					:key="media.id"
+					:class="{ image: media.type.startsWith('image') }"
+					:data-id="media.id"
+					:media="media"
+					:raw="raw"
+				/>
 			</div>
 		</div>
 	</div>
@@ -43,8 +34,7 @@ import PhotoSwipeLightbox from "photoswipe/lightbox";
 import PhotoSwipe from "photoswipe";
 import "photoswipe/style.css";
 import XBanner from "@/components/MkMediaBanner.vue";
-import XImage from "@/components/MkMediaImage.vue";
-import XVideo from "@/components/MkMediaVideo.vue";
+import XMedia from "@/components/MkMedia.vue";
 import * as os from "@/os";
 import { FILE_TYPE_BROWSERSAFE } from "@/const";
 import { defaultStore } from "@/store";
diff --git a/packages/client/src/components/MkMediaVideo.vue b/packages/client/src/components/MkMediaVideo.vue
deleted file mode 100644
index 53dc6a8ab8..0000000000
--- a/packages/client/src/components/MkMediaVideo.vue
+++ /dev/null
@@ -1,169 +0,0 @@
-<template>
-	<div
-		v-if="hide"
-		class="icozogqfvdetwohsdglrbswgrejoxbdj"
-		@click="hide = false"
-	>
-		<div>
-			<b
-				><i class="ph-warning ph-bold ph-lg"></i>
-				{{ i18n.ts.sensitive }}</b
-			>
-			<span>{{ i18n.ts.clickToShow }}</span>
-		</div>
-	</div>
-	<div v-else class="video" :class="{ mini }">
-		<VuePlyr
-			ref="plyr"
-			:options="{
-				controls: [
-					'play-large',
-					'play',
-					'progress',
-					'current-time',
-					'mute',
-					'volume',
-					'pip',
-					'download',
-					'fullscreen',
-				],
-				disableContextMenu: false,
-			}"
-		>
-			<video
-				:poster="video.thumbnailUrl"
-				:aria-label="video.comment"
-				preload="none"
-				controls
-				@contextmenu.stop
-			>
-				<source :src="video.url" :type="video.type" />
-			</video>
-		</VuePlyr>
-		<div class="buttons">
-			<button
-				v-if="video.comment"
-				v-tooltip="i18n.ts.alt"
-				class="_button"
-				@click.stop="captionPopup"
-			>
-				<i class="ph-subtitles ph-bold ph-lg"></i>
-			</button>
-			<button
-				v-tooltip="i18n.ts.hide"
-				class="_button"
-				@click="hide = true"
-			>
-				<i class="ph-eye-slash ph-bold ph-lg"></i>
-			</button>
-		</div>
-	</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref } from "vue";
-import VuePlyr from "vue-plyr";
-import type * as misskey from "calckey-js";
-import { defaultStore } from "@/store";
-import "vue-plyr/dist/vue-plyr.css";
-import { i18n } from "@/i18n";
-import * as os from "@/os";
-
-const props = defineProps<{
-	video: misskey.entities.DriveFile;
-}>();
-
-const plyr = ref();
-const mini = ref(false);
-
-const hide = ref(
-	defaultStore.state.nsfw === "force"
-		? true
-		: props.video.isSensitive && defaultStore.state.nsfw !== "ignore"
-);
-
-function captionPopup() {
-	os.alert({
-		type: "info",
-		text: props.video.comment,
-	});
-}
-
-onMounted(() => {
-	mini.value = plyr.value.player.media.scrollWidth < 300;
-	if (mini.value) {
-		plyr.value.player.on("play", () => {
-			plyr.value.player.fullscreen.enter();
-		});
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.video {
-	position: relative;
-	--plyr-color-main: var(--accent);
-
-	> .buttons {
-		display: flex;
-		gap: 4px;
-		position: absolute;
-		border-radius: 6px;
-		overflow: hidden;
-		top: 12px;
-		right: 12px;
-		> * {
-			background-color: var(--accentedBg);
-			-webkit-backdrop-filter: var(--blur, blur(15px));
-			backdrop-filter: var(--blur, blur(15px));
-			color: var(--accent);
-			font-size: 0.8em;
-			padding: 6px 8px;
-			text-align: center;
-		}
-	}
-
-	> video {
-		display: flex;
-		justify-content: center;
-		align-items: center;
-
-		font-size: 3.5em;
-		overflow: hidden;
-		background-position: center;
-		background-size: cover;
-		width: 100%;
-		height: 100%;
-	}
-
-	&.mini {
-		:deep(.plyr:not(:fullscreen)) {
-			min-width: unset !important;
-			.plyr__control--overlaid,
-			.plyr__progress__container,
-			.plyr__volume,
-			[data-plyr="fullscreen"] {
-				display: none;
-			}
-		}
-	}
-}
-
-.icozogqfvdetwohsdglrbswgrejoxbdj {
-	display: flex;
-	justify-content: center;
-	align-items: center;
-	background: #111;
-	color: #fff;
-
-	> div {
-		display: table-cell;
-		text-align: center;
-		font-size: 12px;
-
-		> b {
-			display: block;
-		}
-	}
-}
-</style>

From b37ba33c125f085716e75f9d54784b91cacc5ee2 Mon Sep 17 00:00:00 2001
From: freeplay <freeplay@duck.com>
Date: Mon, 3 Jul 2023 00:02:36 -0400
Subject: [PATCH 09/11] feat: show alt button even when content hidden

---
 packages/client/src/components/MkMedia.vue | 115 +++++++++++----------
 1 file changed, 60 insertions(+), 55 deletions(-)

diff --git a/packages/client/src/components/MkMedia.vue b/packages/client/src/components/MkMedia.vue
index 36e52ef429..990ec752d8 100644
--- a/packages/client/src/components/MkMedia.vue
+++ b/packages/client/src/components/MkMedia.vue
@@ -1,62 +1,64 @@
 <template>
-	<button v-if="hide" class="qjewsnkg" @click="hide = false">
-		<ImgWithBlurhash
-			:hash="media.blurhash"
-			:title="media.comment"
-			:alt="media.comment"
-		/>
-		<div class="text">
-			<div class="wrapper">
-				<b style="display: block"
-					><i class="ph-warning ph-bold ph-lg"></i>
-					{{ i18n.ts.sensitive }}</b
-				>
-				<span style="display: block">{{ i18n.ts.clickToShow }}</span>
-			</div>
-		</div>
-	</button>
-	<div v-else class="gqnyydlz media" :class="{ mini: plyrMini }">
-		<a 
-			v-if="media.type.startsWith('image')" 
-			:href="media.url"
-		>
+	<div class="media" :class="{ mini: plyrMini }">
+		<button v-if="hide" class="hidden" @click="hide = false">
 			<ImgWithBlurhash
 				:hash="media.blurhash"
-				:src="url"
+				:title="media.comment"
 				:alt="media.comment"
-				:type="media.type"
-				:cover="false"
 			/>
-			<div v-if="media.type === 'image/gif'" class="gif">GIF</div>
-		</a>
-		<VuePlyr
-			v-if="media.type.startsWith('video')" 
-			ref="plyr"
-			:options="{
-				controls: [
-					'play-large',
-					'play',
-					'progress',
-					'current-time',
-					'mute',
-					'volume',
-					'pip',
-					'download',
-					'fullscreen',
-				],
-				disableContextMenu: false,
-			}"
-		>
-			<video
-				:poster="media.thumbnailUrl"
-				:aria-label="media.comment"
-				preload="none"
-				controls
-				@contextmenu.stop
+			<div class="text">
+				<div class="wrapper">
+					<b style="display: block"
+						><i class="ph-warning ph-bold ph-lg"></i>
+						{{ i18n.ts.sensitive }}</b
+					>
+					<span style="display: block">{{ i18n.ts.clickToShow }}</span>
+				</div>
+			</div>
+		</button>
+		<template v-else>
+			<a 
+				v-if="media.type.startsWith('image')" 
+				:href="media.url"
 			>
-				<source :src="media.url" :type="media.type" />
-			</video>
-		</VuePlyr>
+				<ImgWithBlurhash
+					:hash="media.blurhash"
+					:src="url"
+					:alt="media.comment"
+					:type="media.type"
+					:cover="false"
+				/>
+				<div v-if="media.type === 'image/gif'" class="gif">GIF</div>
+			</a>
+			<VuePlyr
+				v-if="media.type.startsWith('video')"
+				ref="plyr"
+				:options="{
+					controls: [
+						'play-large',
+						'play',
+						'progress',
+						'current-time',
+						'mute',
+						'volume',
+						'pip',
+						'download',
+						'fullscreen',
+					],
+					disableContextMenu: false,
+				}"
+			>
+				<video
+					:poster="media.thumbnailUrl"
+					:aria-label="media.comment"
+					preload="none"
+					controls
+					@contextmenu.stop
+				>
+					<source :src="media.url" :type="media.type" />
+				</video>
+			</VuePlyr>
+		</template>
 		<div class="buttons">
 			<button
 				v-if="media.comment"
@@ -67,9 +69,10 @@
 				<i class="ph-subtitles ph-bold ph-lg"></i>
 			</button>
 			<button
+				v-if="!hide"
 				v-tooltip="i18n.ts.hide"
 				class="_button"
-				@click="hide = true"
+				@click.stop="hide = true"
 			>
 				<i class="ph-eye-slash ph-bold ph-lg"></i>
 			</button>
@@ -141,9 +144,11 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
-.qjewsnkg {
+.hidden {
 	all: unset;
 	position: relative;
+	width: 100%;
+	height: 100%;
 
 	> .text {
 		position: relative;

From 4c09522f764ff0abfd1deab15e2d80b1dc2dca38 Mon Sep 17 00:00:00 2001
From: Claire <baffling4945@outlook.com>
Date: Mon, 3 Jul 2023 04:10:24 +0000
Subject: [PATCH 10/11] chore: Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1816 of 1816 strings)

Translation: Calckey/locales
Translate-URL: https://hosted.weblate.org/projects/calckey/locales/zh_Hans/
---
 locales/zh-CN.yml | 52 +++++++++++++++++++++++------------------------
 1 file changed, 26 insertions(+), 26 deletions(-)

diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 110149d737..a5693f8c26 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -1,5 +1,5 @@
 _lang_: "简体中文"
-headlineMisskey: "通过帖子连接在一起的网络"
+headlineMisskey: "一个开源、去中心化的社交媒体平台,永远免费!🚀"
 introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n\
   来探索新的世界吧!🚀"
 monthAndDay: "{month}月 {day}日"
@@ -359,7 +359,7 @@ antennaSource: "接收来源"
 antennaKeywords: "包含关键字"
 antennaExcludeKeywords: "排除关键字"
 antennaKeywordsDescription: "使用空格分隔会产生AND规范,并且使用换行符分隔会产生OR规范"
-notifyAntenna: "开启通知"
+notifyAntenna: "新帖子通知"
 withFileAntenna: "仅带有附件的帖子"
 enableServiceworker: "启用ServiceWorker"
 antennaUsersDescription: "指定用户名,用换行符分隔"
@@ -535,7 +535,7 @@ updateRemoteUser: "更新远程用户信息"
 deleteAllFiles: "删除所有文件"
 deleteAllFilesConfirm: "要删除所有文件吗?"
 removeAllFollowing: "取消所有关注"
-removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。"
+removeAllFollowingDescription: "取消 {host} 的所有关注者。如果服务器已不存在,请执行它。"
 userSuspended: "该用户已被冻结。"
 userSilenced: "该用户已被禁言。"
 yourAccountSuspendedTitle: "账户已被冻结"
@@ -626,13 +626,13 @@ sample: "示例"
 abuseReports: "举报"
 reportAbuse: "举报"
 reportAbuseOf: "举报{name}"
-fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写URL地址。"
+fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写 URL 地址。"
 abuseReported: "内容已发送。感谢您提交信息。"
 reporter: "举报者"
 reporteeOrigin: "举报来源"
 reporterOrigin: "举报者来源"
-forwardReport: "将该举报信息转发给远程实例"
-forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。"
+forwardReport: "将该举报信息转发给远程服务器"
+forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。"
 send: "发送"
 abuseMarkAsResolved: "处理完毕"
 openInNewTab: "在新标签页中打开"
@@ -650,16 +650,16 @@ createNew: "新建"
 optional: "可选"
 createNewClip: "新建便签"
 unclip: "移除便签"
-confirmToUnclipAlreadyClippedNote: "本帖已包含在便签\"{name}\"里。您想要将本帖从该便签中移除吗?"
+confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?"
 public: "公开"
 i18nInfo: "Calckey已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过{link}帮助翻译。"
 manageAccessTokens: "管理 Access Tokens"
 accountInfo: "账户信息"
 notesCount: "帖子数量"
 repliesCount: "回复数量"
-renotesCount: "转帖数量"
+renotesCount: "推贴数量"
 repliedCount: "回复数"
-renotedCount: "转发数"
+renotedCount: "收到的推贴数"
 followingCount: "正在关注数量"
 followersCount: "关注者数量"
 sentReactionsCount: "发送回应数"
@@ -703,7 +703,7 @@ onlineUsersCount: "{n}人在线"
 nUsers: "{n}用户"
 nNotes: "{n} 帖子"
 sendErrorReports: "发送错误报告"
-sendErrorReportsDescription: "启用后,如果出现问题,可以与Misskey共享详细的错误信息,从而帮助提高软件的质量。"
+sendErrorReportsDescription: "启用后,如果出现问题,可以与 Misskey 共享详细的错误信息,从而帮助提高软件的质量。"
 myTheme: "我的主题"
 backgroundColor: "背景"
 accentColor: "强调色"
@@ -727,7 +727,7 @@ capacity: "容量"
 inUse: "已使用"
 editCode: "编辑代码"
 apply: "应用"
-receiveAnnouncementFromInstance: "从实例接收通知"
+receiveAnnouncementFromInstance: "从服务器接收通知"
 emailNotification: "邮件通知"
 publish: "发布"
 inChannelSearch: "频道内搜索"
@@ -839,8 +839,8 @@ themeColor: "服务器滚动条颜色"
 size: "大小"
 numberOfColumn: "列数"
 searchByGoogle: "Google"
-instanceDefaultLightTheme: "实例默认浅色主题"
-instanceDefaultDarkTheme: "实例默认深色主题"
+instanceDefaultLightTheme: "服务器默认浅色主题"
+instanceDefaultDarkTheme: "服务器默认深色主题"
 instanceDefaultThemeDescription: "以对象格式键入主题代码"
 mutePeriod: "屏蔽期限"
 indefinitely: "永久"
@@ -863,7 +863,7 @@ check: "检查"
 driveCapOverrideLabel: "變更此用戶的雲端硬碟容量上限"
 driveCapOverrideCaption: "设定为 0 以下则会解除此限制。"
 requireAdminForView: "需要使用管理员账户登录才能查看。"
-isSystemAccount: "该账号由系统自动创建和管理。"
+isSystemAccount: "该账号由系统自动创建和管理。请不要修改、编辑、删除或以其他方式篡改这个账户,否则可能会破坏你的服务器。"
 typeToConfirm: "输入 {x} 以确认操作。"
 deleteAccount: "删除账户"
 document: "文档"
@@ -889,7 +889,7 @@ cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法
 cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。"
 beta: "测试"
 enableAutoSensitive: "自动 NSFW 识别"
-enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。"
+enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。"
 activeEmailValidationDescription: "积极地验证用户的电子邮件地址,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。"
 navbar: "导航栏"
 shuffle: "随机"
@@ -935,8 +935,8 @@ _ad:
   reduceFrequencyOfThisAd: "减少此广告的频率"
 _forgotPassword:
   enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。"
-  ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。"
-  contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。"
+  ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系服务器管理员。"
+  contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。"
 _gallery:
   my: "我的图库"
   liked: "喜欢的图片"
@@ -1109,10 +1109,10 @@ _wordMute:
   hard: "硬屏蔽"
   mutedNotes: "已静音的帖子"
 _instanceMute:
-  instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。"
+  instanceMuteDescription: "屏蔽列出服务器中的所有帖子和转帖,包括服务器的用户回复。"
   instanceMuteDescription2: "设置时用换行符来分隔"
-  title: "隐藏实例已设置的帖子。"
-  heading: "屏蔽实例"
+  title: "隐藏服务器已设置的帖子。"
+  heading: "要静音的服务器列表"
 _theme:
   explore: "寻找主题"
   install: "安装主题"
@@ -1215,11 +1215,11 @@ _tutorial:
   step1_1: "欢迎!"
   step1_2: "让我们把你安排好。你很快就会启动并运行!"
   step2_1: "首先,请完成您的个人资料。"
-  step2_2: "通过提供一些关于你自己的信息,其他人会更容易了解他们是否想看到你的帖子或关注你。"
-  step3_1: "现在是时候跟随一些人了!"
+  step2_2: "提供一些关于你的信息,让其他人更容易知道他们是否想看你的帖子或关注你。"
+  step3_1: "现在是时候关注一些人了!"
   step3_2: "你的主页和社交馈送是基于你所关注的人,所以试着先关注几个账户。{n点击个人资料右上角的加号圈就可以关注它。"
   step4_1: "让我们出去找你。"
-  step4_2: "对于他们的第一条信息,有些人喜欢做{introduction}或一个简单的 \"hello world!\""
+  step4_2: "对于第一条帖子,可以做一个 {introduction} 或一个简单的 \"hello world!\""
   step5_1: "时间线,无处不在的时间线!"
   step5_2: "您的服务器已启用{timelines}种不同的时间线。"
   step5_3: "主页{icon}时间线是你可以看到你关注账户的帖子的时间线。"
@@ -1372,11 +1372,11 @@ _poll:
   remainingSeconds: "{s}秒后截止"
 _visibility:
   public: "公开"
-  publicDescription: "您的帖子将出现在全局时间线上"
+  publicDescription: "您的帖子将出现在公共时间线上"
   home: "不公开"
   homeDescription: "仅发送至首页的时间线"
   followers: "仅关注者"
-  followersDescription: "仅发送至关注者"
+  followersDescription: "仅对你的关注者和提及的用户可见"
   specified: "指定用户"
   specifiedDescription: "仅发送至指定用户"
   localOnly: "仅限本地"
@@ -1522,7 +1522,7 @@ _pages:
     note: "嵌入的帖子"
     _note:
       id: "帖子ID"
-      idDescription: "您也可以通过粘贴帖子的URL来进行设置。"
+      idDescription: "你也可以将帖子 URL 粘贴到此处。"
       detailed: "显示详细信息"
     switch: "开关"
     _switch:

From c0d1f76f6f70a96e4f88b300ffa1bcf4131ade4e Mon Sep 17 00:00:00 2001
From: wuhang2003 <i@zwh.moe>
Date: Mon, 3 Jul 2023 04:18:27 +0000
Subject: [PATCH 11/11] chore: Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1816 of 1816 strings)

Translation: Calckey/locales
Translate-URL: https://hosted.weblate.org/projects/calckey/locales/zh_Hans/
---
 locales/zh-CN.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index a5693f8c26..6c4854f26a 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -1,6 +1,6 @@
 _lang_: "简体中文"
 headlineMisskey: "一个开源、去中心化的社交媒体平台,永远免费!🚀"
-introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n\
+introMisskey: "欢迎!Calckey 是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n\
   来探索新的世界吧!🚀"
 monthAndDay: "{month}月 {day}日"
 search: "搜索"