Merge branch 'develop' into stories/components-ab

This commit is contained in:
Acid Chicken (硫酸鶏) 2023-04-07 23:50:56 +09:00 committed by GitHub
commit 20789bf10c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 2122 additions and 1816 deletions

View file

@ -1,8 +1,15 @@
import Redis from 'ioredis';
import { loadConfig } from './built/config.js';
import { createRedisConnection } from './built/redis.js';
const config = loadConfig();
const redis = createRedisConnection(config);
const redis = new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {

View file

@ -0,0 +1,17 @@
export class AvatarUrlAndBannerUrl1680775031481 {
name = 'AvatarUrlAndBannerUrl1680775031481'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`);
await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`);
await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`);
await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`);
}
}

View file

@ -22,24 +22,24 @@
"test-and-coverage": "pnpm jest-and-coverage"
},
"optionalDependencies": {
"@swc/core-android-arm64": "^1.3.11",
"@swc/core-darwin-arm64": "^1.3.42",
"@swc/core-darwin-x64": "^1.3.42",
"@swc/core-linux-arm-gnueabihf": "^1.3.42",
"@swc/core-linux-arm64-gnu": "^1.3.42",
"@swc/core-linux-arm64-musl": "^1.3.42",
"@swc/core-linux-x64-gnu": "^1.3.42",
"@swc/core-linux-x64-musl": "^1.3.42",
"@swc/core-win32-arm64-msvc": "^1.3.42",
"@swc/core-win32-ia32-msvc": "^1.3.42",
"@swc/core-win32-x64-msvc": "^1.3.42",
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.3.46",
"@swc/core-darwin-x64": "1.3.46",
"@swc/core-linux-arm-gnueabihf": "1.3.46",
"@swc/core-linux-arm64-gnu": "1.3.46",
"@swc/core-linux-arm64-musl": "1.3.46",
"@swc/core-linux-x64-gnu": "1.3.46",
"@swc/core-linux-x64-musl": "1.3.46",
"@swc/core-win32-arm64-msvc": "1.3.46",
"@swc/core-win32-ia32-msvc": "1.3.46",
"@swc/core-win32-x64-msvc": "1.3.46",
"@tensorflow/tfjs": "4.2.0",
"@tensorflow/tfjs-node": "4.2.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.301.0",
"@aws-sdk/lib-storage": "3.301.0",
"@aws-sdk/node-http-handler": "3.296.0",
"@aws-sdk/client-s3": "3.306.0",
"@aws-sdk/lib-storage": "3.306.0",
"@aws-sdk/node-http-handler": "3.306.0",
"@bull-board/api": "5.0.0",
"@bull-board/fastify": "5.0.0",
"@bull-board/ui": "5.0.0",
@ -49,15 +49,15 @@
"@fastify/cors": "8.2.1",
"@fastify/http-proxy": "9.0.0",
"@fastify/multipart": "7.5.0",
"@fastify/static": "6.9.0",
"@fastify/static": "6.10.0",
"@fastify/view": "7.4.1",
"@nestjs/common": "9.3.12",
"@nestjs/core": "9.3.12",
"@nestjs/testing": "9.3.12",
"@nestjs/common": "9.4.0",
"@nestjs/core": "9.4.0",
"@nestjs/testing": "9.4.0",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.0.2",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.42",
"@swc/core": "1.3.46",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
@ -136,8 +136,8 @@
"tsc-alias": "1.8.5",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.11",
"typescript": "5.0.2",
"typeorm": "0.3.13",
"typescript": "5.0.3",
"ulid": "2.3.0",
"unzipper": "0.10.11",
"uuid": "9.0.0",
@ -190,8 +190,8 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"aws-sdk-client-mock": "^2.1.1",
"cross-env": "7.0.3",
"eslint": "8.37.0",

View file

@ -2,18 +2,15 @@ import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { createRedisConnection } from '@/redis.js';
import { DI } from './di-symbols.js';
import { loadConfig } from './config.js';
import { createPostgresDataSource } from './postgres.js';
import { RepositoryModule } from './models/RepositoryModule.js';
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
const config = loadConfig();
const $config: Provider = {
provide: DI.config,
useValue: config,
useValue: loadConfig(),
};
const $db: Provider = {
@ -28,18 +25,31 @@ const $db: Provider = {
const $redis: Provider = {
provide: DI.redis,
useFactory: (config) => {
const redisClient = createRedisConnection(config);
return redisClient;
return new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
},
inject: [DI.config],
};
const $redisSubscriber: Provider = {
provide: DI.redisSubscriber,
const $redisForPubsub: Provider = {
provide: DI.redisForPubsub,
useFactory: (config) => {
const redisSubscriber = createRedisConnection(config);
redisSubscriber.subscribe(config.host);
return redisSubscriber;
const redis = new Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
redis.subscribe(config.host);
return redis;
},
inject: [DI.config],
};
@ -47,14 +57,14 @@ const $redisSubscriber: Provider = {
@Global()
@Module({
imports: [RepositoryModule],
providers: [$config, $db, $redis, $redisSubscriber],
exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule],
providers: [$config, $db, $redis, $redisForPubsub],
exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
constructor(
@Inject(DI.db) private db: DataSource,
@Inject(DI.redis) private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis,
) {}
async onApplicationShutdown(signal: string): Promise<void> {
@ -69,7 +79,7 @@ export class GlobalModule implements OnApplicationShutdown {
await Promise.all([
this.db.destroy(),
this.redisClient.disconnect(),
this.redisSubscriber.disconnect(),
this.redisForPubsub.disconnect(),
]);
}
}

View file

@ -33,6 +33,22 @@ export type Source = {
db?: number;
prefix?: string;
};
redisForPubsub?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForJobQueue?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
elasticsearch: {
host: string;
port: number;
@ -91,6 +107,8 @@ export type Mixin = {
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
redisForPubsub: NonNullable<Source['redisForPubsub']>;
redisForJobQueue: NonNullable<Source['redisForJobQueue']>;
};
export type Config = Source & Mixin;
@ -151,6 +169,8 @@ export function loadConfig() {
: null;
if (!config.redis.prefix) config.redis.prefix = mixin.host;
if (config.redisForPubsub == null) config.redisForPubsub = config.redis;
if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis;
return Object.assign(config, mixin);
}

View file

@ -27,8 +27,8 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown {
this.antennasFetched = false;
this.antennas = [];
this.redisSubscriber.on('message', this.onRedisMessage);
this.redisForPubsub.on('message', this.onRedisMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onRedisMessage);
this.redisForPubsub.off('message', this.onRedisMessage);
}
@bindThis

View file

@ -27,8 +27,8 @@ export class CacheService implements OnApplicationShutdown {
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -116,7 +116,7 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@ -167,6 +167,6 @@ export class CacheService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View file

@ -1,24 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Emoji } from '@/models/entities/Emoji.js';
import type { EmojisRepository, Note } from '@/models/index.js';
import type { EmojisRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { Config } from '@/config.js';
import { ReactionService } from '@/core/ReactionService.js';
import { query } from '@/misc/prelude/url.js';
@Injectable()
export class CustomEmojiService {
private cache: MemoryKVCache<Emoji | null>;
public localEmojisCache: RedisSingleCache<Map<string, Emoji>>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.config)
private config: Config,
@ -32,9 +36,16 @@ export class CustomEmojiService {
private idService: IdService,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private reactionService: ReactionService,
) {
this.cache = new MemoryKVCache<Emoji | null>(1000 * 60 * 60 * 12);
this.localEmojisCache = new RedisSingleCache<Map<string, Emoji>>(this.redisClient, 'localEmojis', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
toRedisConverter: (value) => JSON.stringify(value.values()),
fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
});
}
@bindThis
@ -60,7 +71,7 @@ export class CustomEmojiService {
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) {
await this.db.queryResultCache?.remove(['meta_emojis']);
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiAdded', {
emoji: await this.emojiEntityService.packDetailed(emoji.id),
@ -70,6 +81,146 @@ export class CustomEmojiService {
return emoji;
}
@bindThis
public async update(id: Emoji['id'], data: {
name?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
}): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
name: data.name,
category: data.category,
aliases: data.aliases,
license: data.license,
});
this.localEmojisCache.refresh();
const updated = await this.emojiEntityService.packDetailed(emoji.id);
if (emoji.name === data.name) {
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: [updated],
});
} else {
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: [await this.emojiEntityService.packDetailed(emoji)],
});
this.globalEventService.publishBroadcastStream('emojiAdded', {
emoji: updated,
});
}
}
@bindThis
public async addAliasesBulk(ids: Emoji['id'][], aliases: string[]) {
const emojis = await this.emojisRepository.findBy({
id: In(ids),
});
for (const emoji of emojis) {
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
aliases: [...new Set(emoji.aliases.concat(aliases))],
});
}
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setAliasesBulk(ids: Emoji['id'][], aliases: string[]) {
await this.emojisRepository.update({
id: In(ids),
}, {
updatedAt: new Date(),
aliases: aliases,
});
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async removeAliasesBulk(ids: Emoji['id'][], aliases: string[]) {
const emojis = await this.emojisRepository.findBy({
id: In(ids),
});
for (const emoji of emojis) {
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
aliases: emoji.aliases.filter(x => !aliases.includes(x)),
});
}
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setCategoryBulk(ids: Emoji['id'][], category: string | null) {
await this.emojisRepository.update({
id: In(ids),
}, {
updatedAt: new Date(),
category: category,
});
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async delete(id: Emoji['id']) {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
await this.emojisRepository.delete(emoji.id);
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: [await this.emojiEntityService.packDetailed(emoji)],
});
}
@bindThis
public async deleteBulk(ids: Emoji['id'][]) {
const emojis = await this.emojisRepository.findBy({
id: In(ids),
});
for (const emoji of emojis) {
await this.emojisRepository.delete(emoji.id);
}
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: await this.emojiEntityService.packDetailedMany(emojis),
});
}
@bindThis
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
// クエリに使うホスト
@ -84,7 +235,7 @@ export class CustomEmojiService {
}
@bindThis
private parseEmojiStr(emojiName: string, noteUserHost: string | null) {
public parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
if (!match) return { name: null, host: null };
@ -143,30 +294,6 @@ export class CustomEmojiService {
return res;
}
@bindThis
public aggregateNoteEmojis(notes: Note[]) {
let emojis: { name: string | null; host: string | null; }[] = [];
for (const note of notes) {
emojis = emojis.concat(note.emojis
.map(e => this.parseEmojiStr(e, note.userHost)));
if (note.renote) {
emojis = emojis.concat(note.renote.emojis
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
if (note.renote.user) {
emojis = emojis.concat(note.renote.user.emojis
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
}
}
const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
emojis = emojis.concat(customReactions);
if (note.user) {
emojis = emojis.concat(note.user.emojis
.map(e => this.parseEmojiStr(e, note.userHost)));
}
}
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
}
/**
*
*/

View file

@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js';
@ -9,23 +10,40 @@ import { bindThis } from '@/decorators.js';
@Injectable()
export class FederatedInstanceService {
private cache: MemoryKVCache<Instance>;
public federatedInstanceCache: RedisKVCache<Instance | null>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private utilityService: UtilityService,
private idService: IdService,
) {
this.cache = new MemoryKVCache<Instance>(1000 * 60 * 60);
this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', {
lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: 1000 * 60 * 30, // 30m
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => {
const parsed = JSON.parse(value);
return {
...parsed,
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
};
},
});
}
@bindThis
public async fetch(host: string): Promise<Instance> {
host = this.utilityService.toPuny(host);
const cached = this.cache.get(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached) return cached;
const index = await this.instancesRepository.findOneBy({ host });
@ -37,10 +55,10 @@ export class FederatedInstanceService {
firstRetrievedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
this.cache.set(host, i);
this.federatedInstanceCache.set(host, i);
return i;
} else {
this.cache.set(host, index);
this.federatedInstanceCache.set(host, index);
return index;
}
}
@ -49,10 +67,10 @@ export class FederatedInstanceService {
public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> {
host = this.utilityService.toPuny(host);
const cached = this.cache.get(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached == null) return;
this.cache.set(host, {
this.federatedInstanceCache.set(host, {
...cached,
...data,
});

View file

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import type { LocalUser } from '@/models/entities/User.js';
import type { UsersRepository } from '@/models/index.js';
import { MemoryCache } from '@/misc/cache.js';
import { MemorySingleCache } from '@/misc/cache.js';
import { DI } from '@/di-symbols.js';
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
import { bindThis } from '@/decorators.js';
@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const;
@Injectable()
export class InstanceActorService {
private cache: MemoryCache<LocalUser>;
private cache: MemorySingleCache<LocalUser>;
constructor(
@Inject(DI.usersRepository)
@ -19,7 +19,7 @@ export class InstanceActorService {
private createSystemUserService: CreateSystemUserService,
) {
this.cache = new MemoryCache<LocalUser>(Infinity);
this.cache = new MemorySingleCache<LocalUser>(Infinity);
}
@bindThis

View file

@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer;
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.db)
private db: DataSource,
@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown {
}, 1000 * 60 * 5);
}
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
clearInterval(this.intervalId);
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View file

@ -20,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js
import { checkWordMute } from '@/misc/check-word-mute.js';
import type { Channel } from '@/models/entities/Channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { MemoryCache } from '@/misc/cache.js';
import { MemorySingleCache } from '@/misc/cache.js';
import type { UserProfile } from '@/models/entities/UserProfile.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -47,7 +47,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
const mutedWordsCache = new MemoryCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';

View file

@ -43,6 +43,7 @@ export class NotificationService implements OnApplicationShutdown {
@bindThis
public async readAllNotification(
userId: User['id'],
force = false,
) {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
@ -57,7 +58,7 @@ export class NotificationService implements OnApplicationShutdown {
this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId);
if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
return this.postReadAllNotifications(userId);
}
}
@ -95,7 +96,7 @@ export class NotificationService implements OnApplicationShutdown {
...data,
} as Notification;
this.redisClient.xadd(
const redisIdPromise = this.redisClient.xadd(
`notificationTimeline:${notifieeId}`,
'MAXLEN', '~', '300',
`${this.idService.parse(notification.id).date.getTime()}-*`,
@ -109,7 +110,7 @@ export class NotificationService implements OnApplicationShutdown {
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return;
if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return;
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);

View file

@ -8,13 +8,13 @@ import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, End
function q<T>(config: Config, name: string, limitPerSec = -1) {
return new Bull<T>(name, {
redis: {
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
db: config.redis.db ?? 0,
port: config.redisForJobQueue.port,
host: config.redisForJobQueue.host,
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
password: config.redisForJobQueue.pass,
db: config.redisForJobQueue.db ?? 0,
},
prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue',
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue',
limiter: limitPerSec > 0 ? {
max: limitPerSec,
duration: 1000,

View file

@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { RemoteUser, User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
@ -20,6 +19,7 @@ import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
const FALLBACK = '❤';
@ -60,9 +60,6 @@ export class ReactionService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -74,6 +71,7 @@ export class ReactionService {
private utilityService: UtilityService,
private metaService: MetaService,
private customEmojiService: CustomEmojiService,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService,
@ -104,7 +102,6 @@ export class ReactionService {
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) {
reaction = '❤️';
} else {
// TODO: cache
reaction = await this.toDbReaction(reaction, user.host);
}
@ -158,21 +155,22 @@ export class ReactionService {
// カスタム絵文字リアクションだったら絵文字情報も送る
const decodedReaction = this.decodeReaction(reaction);
// TODO: Cache
const emoji = await this.emojisRepository.findOne({
where: {
name: decodedReaction.name,
host: decodedReaction.host ?? IsNull(),
},
select: ['name', 'host', 'originalUrl', 'publicUrl'],
});
const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null
? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name)
: await this.emojisRepository.findOne(
{
where: {
name: decodedReaction.name,
host: decodedReaction.host,
},
});
this.globalEventService.publishNoteStream(note.id, 'reacted', {
reaction: decodedReaction.reaction,
emoji: emoji != null ? {
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
emoji: customEmoji != null ? {
name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
url: customEmoji.publicUrl || customEmoji.originalUrl,
} : null,
userId: user.id,
});
@ -311,10 +309,12 @@ export class ReactionService {
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
if (custom) {
const name = custom[1];
const emoji = await this.emojisRepository.findOneBy({
host: reacterHost ?? IsNull(),
name,
});
const emoji = reacterHost == null
? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
: await this.emojisRepository.findOneBy({
host: reacterHost,
name,
});
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
}

View file

@ -3,7 +3,7 @@ import { IsNull } from 'typeorm';
import type { LocalUser, User } from '@/models/entities/User.js';
import type { RelaysRepository, UsersRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { MemoryCache } from '@/misc/cache.js';
import { MemorySingleCache } from '@/misc/cache.js';
import type { Relay } from '@/models/entities/Relay.js';
import { QueueService } from '@/core/QueueService.js';
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const;
@Injectable()
export class RelayService {
private relaysCache: MemoryCache<Relay[]>;
private relaysCache: MemorySingleCache<Relay[]>;
constructor(
@Inject(DI.usersRepository)
@ -30,7 +30,7 @@ export class RelayService {
private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService,
) {
this.relaysCache = new MemoryCache<Relay[]>(1000 * 60 * 10);
this.relaysCache = new MemorySingleCache<Relay[]>(1000 * 60 * 10);
}
@bindThis

View file

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { In } from 'typeorm';
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
import { MemoryKVCache, MemoryCache } from '@/misc/cache.js';
import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js';
import type { User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@ -57,15 +57,15 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable()
export class RoleService implements OnApplicationShutdown {
private rolesCache: MemoryCache<Role[]>;
private rolesCache: MemorySingleCache<Role[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<RoleAssignment[]>;
public static AlreadyAssignedError = class extends Error {};
public static NotAssignedError = class extends Error {};
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -84,10 +84,10 @@ export class RoleService implements OnApplicationShutdown {
) {
//this.onMessage = this.onMessage.bind(this);
this.rolesCache = new MemoryCache<Role[]>(Infinity);
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(Infinity);
this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@ -400,6 +400,6 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View file

@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown {
private webhooks: Webhook[] = [];
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
) {
//this.onMessage = this.onMessage.bind(this);
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View file

@ -21,6 +21,8 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { LdSignatureService } from './LdSignatureService.js';
import { ApMfmService } from './ApMfmService.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
@ -50,6 +52,7 @@ export class ApRendererService {
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
private customEmojiService: CustomEmojiService,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private ldSignatureService: LdSignatureService,
@ -272,11 +275,7 @@ export class ApRendererService {
if (reaction.startsWith(':')) {
const name = reaction.replaceAll(':', '');
// TODO: cache
const emoji = await this.emojisRepository.findOneBy({
name,
host: IsNull(),
});
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
if (emoji) object.tag = [this.renderEmoji(emoji)];
}
@ -701,13 +700,9 @@ export class ApRendererService {
private async getEmojis(names: string[]): Promise<Emoji[]> {
if (names == null || names.length === 0) return [];
const emojis = await Promise.all(
names.map(name => this.emojisRepository.findOneBy({
name,
host: IsNull(),
})),
);
const allEmojis = await this.customEmojiService.localEmojisCache.fetch();
const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull);
return emojis.filter(emoji => emoji != null) as Emoji[];
return emojis;
}
}

View file

@ -1,5 +1,6 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import promiseLimit from 'promise-limit';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { PollsRepository, EmojisRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@ -341,15 +342,17 @@ export class ApNoteService {
if (!tags) return [];
const eomjiTags = toArray(tags).filter(isEmoji);
const existingEmojis = await this.emojisRepository.findBy({
host,
name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))),
});
return await Promise.all(eomjiTags.map(async tag => {
const name = tag.name!.replace(/^:/, '').replace(/:$/, '');
const name = tag.name!.replaceAll(':', '');
tag.icon = toSingle(tag.icon);
const exists = await this.emojisRepository.findOneBy({
host,
name,
});
const exists = existingEmojis.find(x => x.name === name);
if (exists) {
if ((tag.updated != null && exists.updatedAt == null)

View file

@ -31,6 +31,7 @@ import type { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@ -49,6 +50,7 @@ const summaryLength = 2048;
export class ApPersonService implements OnModuleInit {
private utilityService: UtilityService;
private userEntityService: UserEntityService;
private driveFileEntityService: DriveFileEntityService;
private idService: IdService;
private globalEventService: GlobalEventService;
private metaService: MetaService;
@ -113,6 +115,7 @@ export class ApPersonService implements OnModuleInit {
onModuleInit() {
this.utilityService = this.moduleRef.get('UtilityService');
this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.idService = this.moduleRef.get('IdService');
this.globalEventService = this.moduleRef.get('GlobalEventService');
this.metaService = this.moduleRef.get('MetaService');
@ -356,32 +359,44 @@ export class ApPersonService implements OnModuleInit {
const avatarId = avatar ? avatar.id : null;
const bannerId = banner ? banner.id : null;
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
const avatarBlurhash = avatar ? avatar.blurhash : null;
const bannerBlurhash = banner ? banner.blurhash : null;
await this.usersRepository.update(user!.id, {
avatarId,
bannerId,
avatarUrl,
bannerUrl,
avatarBlurhash,
bannerBlurhash,
});
user!.avatarId = avatarId;
user!.bannerId = bannerId;
//#endregion
user!.avatarId = avatarId;
user!.bannerId = bannerId;
user!.avatarUrl = avatarUrl;
user!.bannerUrl = bannerUrl;
user!.avatarBlurhash = avatarBlurhash;
user!.bannerBlurhash = bannerBlurhash;
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [] as Emoji[];
});
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [] as Emoji[];
});
const emojiNames = emojis.map(emoji => emoji.name);
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
//#endregion
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
//#endregion
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
return user!;
return user!;
}
/**
@ -463,10 +478,14 @@ export class ApPersonService implements OnModuleInit {
if (avatar) {
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (banner) {
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
// Update user

View file

@ -266,6 +266,7 @@ export class DriveFileEntityService {
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
if (fileIds.length === 0) return new Map();
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
const packedFiles = await this.packMany(files, options);
const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
@ -280,6 +281,7 @@ export class DriveFileEntityService {
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
if (fileIds.length === 0) return [];
const filesMap = await this.packManyByIdsMap(fileIds, options);
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
}

View file

@ -183,6 +183,11 @@ export class NoteEntityService implements OnModuleInit {
// 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない
}
// パフォーマンスのためートが作成されてから1秒以上経っていない場合はリアクションを取得しない
if (note.createdAt.getTime() + 1000 > Date.now()) {
return undefined;
}
const reaction = await this.noteReactionsRepository.findOneBy({
userId: meId,
noteId: note.id,
@ -283,7 +288,7 @@ export class NoteEntityService implements OnModuleInit {
}, options);
const meId = me ? me.id : null;
const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src });
const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] });
const host = note.userHost;
let text = note.text;
@ -395,7 +400,8 @@ export class NoteEntityService implements OnModuleInit {
const myReactionsMap = new Map<Note['id'], NoteReaction | null>();
if (meId) {
const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!);
const targets = [...notes.map(n => n.id), ...renoteIds];
// パフォーマンスのためートが作成されてから1秒以上経っていない場合はリアクションを取得しない
const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds];
const myReactions = await this.noteReactionsRepository.findBy({
userId: meId,
noteId: In(targets),
@ -406,10 +412,10 @@ export class NoteEntityService implements OnModuleInit {
}
}
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
return await Promise.all(notes.map(n => this.pack(n, me, {
...options,
@ -420,6 +426,30 @@ export class NoteEntityService implements OnModuleInit {
})));
}
@bindThis
public aggregateNoteEmojis(notes: Note[]) {
let emojis: { name: string | null; host: string | null; }[] = [];
for (const note of notes) {
emojis = emojis.concat(note.emojis
.map(e => this.customEmojiService.parseEmojiStr(e, note.userHost)));
if (note.renote) {
emojis = emojis.concat(note.renote.emojis
.map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost)));
if (note.renote.user) {
emojis = emojis.concat(note.renote.user.emojis
.map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost)));
}
}
const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
emojis = emojis.concat(customReactions);
if (note.user) {
emojis = emojis.concat(note.user.emojis
.map(e => this.customEmojiService.parseEmojiStr(e, note.userHost)));
}
}
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
}
@bindThis
public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> {
// 指定したユーザーの指定したノートのリノートがいくつあるか数える

View file

@ -108,27 +108,30 @@ export class NotificationEntityService implements OnModuleInit {
) {
if (notifications.length === 0) return [];
const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
let validNotifications = notifications;
const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull);
const notes = noteIds.length > 0 ? await this.notesRepository.find({
where: { id: In(noteIds) },
relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'],
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
}) : [];
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true,
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId));
const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull);
const users = userIds.length > 0 ? await this.usersRepository.find({
where: { id: In(userIds) },
relations: ['avatar', 'banner'],
}) : [];
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, {
detail: false,
});
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, meId, {}, {
return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
packedNotes,
packedUsers,
})));

View file

@ -9,13 +9,13 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { MemoryKVCache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { AntennaService } from '../AntennaService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@ -53,7 +53,7 @@ export class UserEntityService implements OnModuleInit {
private customEmojiService: CustomEmojiService;
private antennaService: AntennaService;
private roleService: RoleService;
private userInstanceCache: MemoryKVCache<Instance | null>;
private federatedInstanceService: FederatedInstanceService;
constructor(
private moduleRef: ModuleRef,
@ -119,7 +119,6 @@ export class UserEntityService implements OnModuleInit {
//private antennaService: AntennaService,
//private roleService: RoleService,
) {
this.userInstanceCache = new MemoryKVCache<Instance | null>(1000 * 60 * 60 * 3);
}
onModuleInit() {
@ -129,6 +128,7 @@ export class UserEntityService implements OnModuleInit {
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.antennaService = this.moduleRef.get('AntennaService');
this.roleService = this.moduleRef.get('RoleService');
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
}
//#region Validators
@ -269,27 +269,6 @@ export class UserEntityService implements OnModuleInit {
);
}
@bindThis
public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
return this.getIdenticonUrl(user);
}
}
@bindThis
public getAvatarUrlSync(user: User): string {
if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
return this.getIdenticonUrl(user);
}
}
@bindThis
public getIdenticonUrl(user: User): string {
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
@ -309,19 +288,23 @@ export class UserEntityService implements OnModuleInit {
includeSecrets: false,
}, options);
let user: User;
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
if (typeof src === 'object') {
user = src;
if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null;
if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null;
} else {
user = await this.usersRepository.findOneOrFail({
where: { id: src },
relations: {
avatar: true,
banner: true,
},
// migration
if (user.avatarId != null && user.avatarUrl === null) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
this.usersRepository.update(user.id, {
avatarUrl: user.avatarUrl,
avatarBlurhash: avatar.blurhash,
});
}
if (user.bannerId != null && user.bannerUrl === null) {
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
this.usersRepository.update(user.id, {
bannerUrl: user.bannerUrl,
bannerBlurhash: banner.blurhash,
});
}
@ -356,14 +339,11 @@ export class UserEntityService implements OnModuleInit {
name: user.name,
username: user.username,
host: user.host,
avatarUrl: this.getAvatarUrlSync(user),
avatarBlurhash: user.avatar?.blurhash ?? null,
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
avatarBlurhash: user.avatarBlurhash,
isBot: user.isBot ?? falsy,
isCat: user.isCat ?? falsy,
instance: user.host ? this.userInstanceCache.fetch(user.host,
() => this.instancesRepository.findOneBy({ host: user.host! }),
v => v != null,
).then(instance => instance ? {
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
@ -386,8 +366,8 @@ export class UserEntityService implements OnModuleInit {
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null,
bannerBlurhash: user.banner?.blurhash ?? null,
bannerUrl: user.bannerUrl,
bannerBlurhash: user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended ?? falsy,

View file

@ -2,7 +2,7 @@ export const DI = {
config: Symbol('config'),
db: Symbol('db'),
redis: Symbol('redis'),
redisSubscriber: Symbol('redisSubscriber'),
redisForPubsub: Symbol('redisForPubsub'),
//#region Repositories
usersRepository: Symbol('usersRepository'),

View file

@ -85,6 +85,90 @@ export class RedisKVCache<T> {
}
}
export class RedisSingleCache<T> {
private redisClient: Redis.Redis;
private name: string;
private lifetime: number;
private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime'];
memoryCacheLifetime: number;
fetcher: RedisSingleCache<T>['fetcher'];
toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
}) {
this.redisClient = redisClient;
this.name = name;
this.lifetime = opts.lifetime;
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher;
this.toRedisConverter = opts.toRedisConverter;
this.fromRedisConverter = opts.fromRedisConverter;
}
@bindThis
public async set(value: T): Promise<void> {
this.memoryCache.set(value);
if (this.lifetime === Infinity) {
await this.redisClient.set(
`singlecache:${this.name}`,
this.toRedisConverter(value),
);
} else {
await this.redisClient.set(
`singlecache:${this.name}`,
this.toRedisConverter(value),
'ex', Math.round(this.lifetime / 1000),
);
}
}
@bindThis
public async get(): Promise<T | undefined> {
const memoryCached = this.memoryCache.get();
if (memoryCached !== undefined) return memoryCached;
const cached = await this.redisClient.get(`singlecache:${this.name}`);
if (cached == null) return undefined;
return this.fromRedisConverter(cached);
}
@bindThis
public async delete(): Promise<void> {
this.memoryCache.delete();
await this.redisClient.del(`singlecache:${this.name}`);
}
/**
* fetcherを呼び出して結果をキャッシュ&
*/
@bindThis
public async fetch(): Promise<T> {
const cachedValue = await this.get();
if (cachedValue !== undefined) {
// Cache HIT
return cachedValue;
}
// Cache MISS
const value = await this.fetcher();
this.set(value);
return value;
}
@bindThis
public async refresh() {
const value = await this.fetcher();
this.set(value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
}
}
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> {
@ -173,12 +257,12 @@ export class MemoryKVCache<T> {
}
}
export class MemoryCache<T> {
export class MemorySingleCache<T> {
private cachedAt: number | null = null;
private value: T | undefined;
private lifetime: number;
constructor(lifetime: MemoryCache<never>['lifetime']) {
constructor(lifetime: MemorySingleCache<never>['lifetime']) {
this.lifetime = lifetime;
}

View file

@ -100,6 +100,26 @@ export class User {
@JoinColumn()
public banner: DriveFile | null;
@Column('varchar', {
length: 512, nullable: true,
})
public avatarUrl: string | null;
@Column('varchar', {
length: 512, nullable: true,
})
public bannerUrl: string | null;
@Column('varchar', {
length: 128, nullable: true,
})
public avatarBlurhash: string | null;
@Column('varchar', {
length: 128, nullable: true,
})
public bannerBlurhash: string | null;
@Index()
@Column('varchar', {
length: 128, array: true, default: '{}',

View file

@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { MemoryCache } from '@/misc/cache.js';
import { MemorySingleCache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import ApRequestChart from '@/core/chart/charts/ap-request.js';
@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js';
@Injectable()
export class DeliverProcessorService {
private logger: Logger;
private suspendedHostsCache: MemoryCache<Instance[]>;
private suspendedHostsCache: MemorySingleCache<Instance[]>;
private latest: string | null;
constructor(
@ -46,7 +46,7 @@ export class DeliverProcessorService {
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
this.suspendedHostsCache = new MemoryCache<Instance[]>(1000 * 60 * 60);
this.suspendedHostsCache = new MemorySingleCache<Instance[]>(1000 * 60 * 60);
}
@bindThis

View file

@ -86,6 +86,10 @@ export class ImportCustomEmojisProcessorService {
continue;
}
const emojiInfo = record.emoji;
if (!/^[a-zA-Z0-9_]+$/.test(emojiInfo.name)) {
this.logger.error(`invalid emojiname: ${emojiInfo.name}`);
continue;
}
const emojiPath = outputPath + '/' + record.fileName;
await this.emojisRepository.delete({
name: emojiInfo.name,

View file

@ -1,13 +0,0 @@
import Redis from 'ioredis';
import { Config } from '@/config.js';
export function createRedisConnection(config: Config): Redis.Redis {
return new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
}

View file

@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { MemoryCache } from '@/misc/cache.js';
import { MemorySingleCache } from '@/misc/cache.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import NotesChart from '@/core/chart/charts/notes.js';
@ -118,7 +118,7 @@ export class NodeinfoServerService {
};
};
const cache = new MemoryCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2());

View file

@ -149,13 +149,12 @@ export class ServerService implements OnApplicationShutdown {
host: (host == null) || (host === this.config.host) ? IsNull() : host,
isSuspended: false,
},
relations: ['avatar'],
});
reply.header('Cache-Control', 'public, max-age=86400');
if (user) {
reply.redirect(this.userEntityService.getAvatarUrlSync(user));
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
} else {
reply.redirect('/static-assets/user-unknown.png');
}

View file

@ -22,8 +22,8 @@ export class StreamingApiServerService {
@Inject(DI.config)
private config: Config,
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -81,7 +81,7 @@ export class StreamingApiServerService {
ev.emit(parsed.channel, parsed.message);
}
this.redisSubscriber.on('message', onRedisMessage);
this.redisForPubsub.on('message', onRedisMessage);
const main = new MainStreamConnection(
this.channelsService,
@ -111,7 +111,7 @@ export class StreamingApiServerService {
connection.once('close', () => {
ev.removeAllListeners();
main.dispose();
this.redisSubscriber.off('message', onRedisMessage);
this.redisForPubsub.off('message', onRedisMessage);
if (intervalId) clearInterval(intervalId);
});

View file

@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
export const meta = {
tags: ['admin'],
@ -26,38 +22,14 @@ export const paramDef = {
required: ['ids', 'aliases'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
const emojis = await this.emojisRepository.findBy({
id: In(ps.ids),
});
for (const emoji of emojis) {
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
aliases: [...new Set(emoji.aliases.concat(ps.aliases))],
});
}
await this.db.queryResultCache?.remove(['meta_emojis']);
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ps.ids),
});
await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases);
});
}
}

View file

@ -90,8 +90,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
license: emoji.license,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
await this.db.queryResultCache?.remove(['meta_emojis']);
this.globalEventService.publishBroadcastStream('emojiAdded', {
emoji: await this.emojiEntityService.packDetailed(copied.id),
});

View file

@ -1,11 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
export const meta = {
tags: ['admin'],
@ -24,38 +19,14 @@ export const paramDef = {
required: ['ids'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private moderationLogService: ModerationLogService,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
const emojis = await this.emojisRepository.findBy({
id: In(ps.ids),
});
for (const emoji of emojis) {
await this.emojisRepository.delete(emoji.id);
await this.db.queryResultCache?.remove(['meta_emojis']);
this.moderationLogService.insertModerationLog(me, 'deleteEmoji', {
emoji: emoji,
});
}
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: await this.emojiEntityService.packDetailedMany(emojis),
});
await this.customEmojiService.deleteBulk(ps.ids);
});
}
}

View file

@ -1,12 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApiError } from '../../../error.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
export const meta = {
tags: ['admin'],
@ -31,38 +25,14 @@ export const paramDef = {
required: ['id'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private moderationLogService: ModerationLogService,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
const emoji = await this.emojisRepository.findOneBy({ id: ps.id });
if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
await this.emojisRepository.delete(emoji.id);
await this.db.queryResultCache?.remove(['meta_emojis']);
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: [await this.emojiEntityService.packDetailed(emoji)],
});
this.moderationLogService.insertModerationLog(me, 'deleteEmoji', {
emoji: emoji,
});
await this.customEmojiService.delete(ps.id);
});
}
}

View file

@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
export const meta = {
tags: ['admin'],
@ -26,38 +22,14 @@ export const paramDef = {
required: ['ids', 'aliases'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
const emojis = await this.emojisRepository.findBy({
id: In(ps.ids),
});
for (const emoji of emojis) {
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)),
});
}
await this.db.queryResultCache?.remove(['meta_emojis']);
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ps.ids),
});
await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases);
});
}
}

View file

@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
export const meta = {
tags: ['admin'],
@ -26,34 +22,14 @@ export const paramDef = {
required: ['ids', 'aliases'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
await this.emojisRepository.update({
id: In(ps.ids),
}, {
updatedAt: new Date(),
aliases: ps.aliases,
});
await this.db.queryResultCache?.remove(['meta_emojis']);
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ps.ids),
});
await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases);
});
}
}

View file

@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
export const meta = {
tags: ['admin'],
@ -28,34 +24,14 @@ export const paramDef = {
required: ['ids'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
await this.emojisRepository.update({
id: In(ps.ids),
}, {
updatedAt: new Date(),
category: ps.category,
});
await this.db.queryResultCache?.remove(['meta_emojis']);
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ps.ids),
});
await this.customEmojiService.setCategoryBulk(ps.ids, ps.category ?? null);
});
}
}

View file

@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -45,51 +41,19 @@ export const paramDef = {
required: ['id', 'name', 'aliases'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService,
private globalEventService: GlobalEventService,
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
const emoji = await this.emojisRepository.findOneBy({ id: ps.id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: ps.name, host: IsNull() });
if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
if (sameNameEmoji != null && sameNameEmoji.id !== ps.id) throw new ApiError(meta.errors.sameNameEmojiExists);
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
await this.customEmojiService.update(ps.id, {
name: ps.name,
category: ps.category,
category: ps.category ?? null,
aliases: ps.aliases,
license: ps.license,
license: ps.license ?? null,
});
await this.db.queryResultCache?.remove(['meta_emojis']);
const updated = await this.emojiEntityService.packDetailed(emoji.id);
if (emoji.name === ps.name) {
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: [updated],
});
} else {
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: [await this.emojiEntityService.packDetailed(emoji)],
});
this.globalEventService.publishBroadcastStream('emojiAdded', {
emoji: updated,
});
}
});
}
}

View file

@ -95,16 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);

View file

@ -93,16 +93,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {

View file

@ -75,16 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
if (me) {

View file

@ -58,10 +58,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
category: 'ASC',
name: 'ASC',
},
cache: {
id: 'meta_emojis',
milliseconds: 3600000, // 1 hour
},
});
return {

View file

@ -19,6 +19,7 @@ import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -148,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private pagesRepository: PagesRepository,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
@ -170,8 +172,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (ps.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility;
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
if (ps.mutedWords !== undefined) {
// TODO: ちゃんと数える
const length = JSON.stringify(ps.mutedWords).length;
@ -217,6 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage);
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (ps.bannerId) {
@ -224,6 +228,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage);
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
if (ps.pinnedPageId) {

View file

@ -49,16 +49,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.visibility = \'public\'')
.andWhere('note.localOnly = FALSE')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.local) {
query.andWhere('note.userHost IS NULL');

View file

@ -57,16 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}));
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) {

View file

@ -53,16 +53,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) })
.andWhere('note.visibility = \'public\'')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });

View file

@ -73,16 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateRepliesQuery(query, me);
if (me) {

View file

@ -91,16 +91,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.setParameters(followingQuery.getParameters());
this.queryService.generateChannelQuery(query, me);

View file

@ -80,16 +80,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me);

View file

@ -60,16 +60,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);

View file

@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
order: {
id: -1,
},
relations: ['user', 'user.avatar', 'user.banner', 'note'],
relations: ['user', 'note'],
});
return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me)));

View file

@ -4,8 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
@ -62,16 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.renoteId = :renoteId', { renoteId: note.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View file

@ -46,16 +46,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.replyId = :replyId', { replyId: ps.noteId })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View file

@ -71,16 +71,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View file

@ -85,16 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
query
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View file

@ -70,16 +70,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];

View file

@ -84,16 +84,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
this.queryService.generateVisibilityQuery(query, me);

View file

@ -24,7 +24,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
this.notificationService.readAllNotification(me.id);
this.notificationService.readAllNotification(me.id, true);
});
}
}

View file

@ -74,16 +74,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.userId = :userId', { userId: user.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) {

View file

@ -419,7 +419,7 @@ export class ClientServerService {
reply.header('Cache-Control', 'public, max-age=15');
return await reply.view('user', {
user, profile, me,
avatarUrl: await this.userEntityService.getAvatarUrl(user),
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
sub: request.params.sub,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
@ -464,7 +464,7 @@ export class ClientServerService {
return await reply.view('note', {
note: _note,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })),
avatarUrl: _note.user.avatarUrl,
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
instanceName: meta.name ?? 'Misskey',
@ -503,7 +503,7 @@ export class ClientServerService {
return await reply.view('page', {
page: _page,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })),
avatarUrl: _page.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
@ -527,7 +527,7 @@ export class ClientServerService {
return await reply.view('flash', {
flash: _flash,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: flash.userId })),
avatarUrl: _flash.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
@ -551,7 +551,7 @@ export class ClientServerService {
return await reply.view('clip', {
clip: _clip,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })),
avatarUrl: _clip.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
@ -573,7 +573,7 @@ export class ClientServerService {
return await reply.view('gallery-post', {
post: _post,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })),
avatarUrl: _post.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,

View file

@ -58,7 +58,7 @@ export class FeedService {
generator: 'Misskey',
description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link,
image: await this.userEntityService.getAvatarUrl(user),
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
feedLinks: {
json: `${author.link}.json`,
atom: `${author.link}.atom`,

View file

@ -172,6 +172,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/* TODO
test('リモートユーザーの投稿は流れない', async () => {
const fired = await waitFire(
ayano, 'localTimeline', // ayano:Local
@ -191,6 +192,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, false);
});
*/
test('ホーム指定の投稿は流れない', async () => {
const fired = await waitFire(
@ -244,6 +246,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/* TODO
test('フォローしているリモートユーザーの投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'hybridTimeline', // ayano:Hybrid
@ -263,6 +266,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, false);
});
*/
test('フォローしているユーザーのダイレクト投稿が流れる', async () => {
const fired = await waitFire(
@ -316,6 +320,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/* TODO
test('フォローしていないリモートユーザーの投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'globalTimeline', // ayano:Global
@ -325,6 +330,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
*/
test('ホーム投稿は流れない', async () => {
const fired = await waitFire(

View file

@ -1,9 +1,7 @@
# (cd path/to/frontend; pnpm tsc -p .storybook)
# (cd path/to/frontend; node .storybook/generate.js)
/changes.js
/generate.js
# (cd path/to/frontend; node .storybook/preload-locale.js)
/preload-locale.js
/locale.ts
# (cd path/to/frontend; node .storybook/preload-theme.js)
/main.js
/preload-theme.js
/themes.ts

View file

@ -0,0 +1,80 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import micromatch from 'micromatch';
import main from './main';
interface Stats {
readonly modules: readonly {
readonly id: string;
readonly name: string;
readonly reasons: readonly {
readonly moduleName: string;
}[];
}[];
}
fs.readFile(
path.resolve(__dirname, '../storybook-static/preview-stats.json')
).then((buffer) => {
const stats: Stats = JSON.parse(buffer.toString());
const keys = new Set(stats.modules.map((stat) => stat.id));
const map = new Map(
Array.from(keys, (key) => [
key,
new Set(
stats.modules
.filter((stat) => stat.id === key)
.flatMap((stat) => stat.reasons)
.map((reason) => reason.moduleName)
),
])
);
const modules = new Set(
process.argv
.slice(2)
.map((arg) =>
path.relative(
path.resolve(__dirname, '..'),
path.resolve(__dirname, '../../..', arg)
)
)
.map((path) => (path.startsWith('.') ? path : `./${path}`))
);
if (
micromatch(Array.from(modules), [
'../../assets/**',
'../../fluent-emojis/**',
'../../locales/**',
'../../misskey-assets/**',
'assets/**',
'public/**',
'../../pnpm-lock.yaml',
]).length
) {
return;
}
for (;;) {
const oldSize = modules.size;
for (const module of Array.from(modules)) {
if (map.has(module)) {
for (const dependency of Array.from(map.get(module)!)) {
modules.add(dependency);
}
}
}
if (modules.size === oldSize) {
break;
}
}
const stories = micromatch(
Array.from(modules),
main.stories.map((story) => `./${path.relative('..', story)}`)
);
if (stories.length) {
for (const story of stories) {
process.stdout.write(` --only-story-files ${story}`);
}
} else {
process.stdout.write(` --skip`);
}
});

View file

@ -17,6 +17,49 @@ export function abuseUserReport() {
};
}
export function galleryPost(isSensitive = false) {
return {
id: 'somepostid',
createdAt: '2016-12-28T22:49:51.000Z',
updatedAt: '2016-12-28T22:49:51.000Z',
userid: 'someuserid',
user: userDetailed(),
title: 'Some post title',
description: 'Some post description',
fileIds: ['somefileid'],
files: [
file(isSensitive),
],
isSensitive,
likedCount: 0,
isLiked: false,
}
}
export function file(isSensitive = false) {
return {
id: 'somefileid',
createdAt: '2016-12-28T22:49:51.000Z',
name: 'somefile.jpg',
type: 'image/jpeg',
md5: 'f6fc51c73dc21b1fb85ead2cdf57530a',
size: 77752,
isSensitive,
blurhash: 'eQAmoa^-MH8w9ZIvNLSvo^$*MwRPbwtSxutRozjEiwR.RjWBoeozog',
properties: {
width: 1024,
height: 270
},
url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
thumbnailUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
comment: null,
folderId: null,
folder: null,
userId: null,
user: null,
};
}
export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed {
return {
id,

View file

@ -397,6 +397,7 @@ function toStories(component: string): string {
Promise.all([
glob('src/components/global/*.vue'),
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
])
.then((globs) => globs.flat())
.then((components) => Promise.all(components.map((component) => {

View file

@ -1,6 +1,7 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/vue3-vite';
import { mergeConfig } from 'vite';
import turbosnap from 'vite-plugin-turbosnap';
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
@ -20,8 +21,13 @@ const config = {
core: {
disableTelemetry: true,
},
async viteFinal(config, options) {
async viteFinal(config) {
return mergeConfig(config, {
plugins: [
turbosnap({
rootDir: config.root ?? process.cwd(),
}),
],
build: {
target: [
'chrome108',

View file

@ -18,5 +18,10 @@
"jsx": "react",
"jsxFactory": "h"
},
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
"files": [
"./changes.ts",
"./generate.tsx",
"./preload-locale.ts",
"./preload-theme.ts"
]
}

View file

@ -58,13 +58,13 @@
"strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.150.1",
"three": "0.151.3",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.5",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typescript": "5.0.2",
"typescript": "5.0.3",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
"vite": "4.2.1",
@ -74,31 +74,32 @@
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "^7.0.2",
"@storybook/addon-essentials": "^7.0.2",
"@storybook/addon-interactions": "^7.0.2",
"@storybook/addon-links": "^7.0.2",
"@storybook/addon-storysource": "^7.0.2",
"@storybook/addons": "^7.0.2",
"@storybook/blocks": "^7.0.2",
"@storybook/core-events": "^7.0.2",
"@storybook/jest": "~0.1.0",
"@storybook/manager-api": "^7.0.2",
"@storybook/preview-api": "^7.0.2",
"@storybook/react": "^7.0.2",
"@storybook/react-vite": "^7.0.2",
"@storybook/testing-library": "~0.0.14-next.1",
"@storybook/theming": "^7.0.2",
"@storybook/types": "^7.0.2",
"@storybook/vue3": "^7.0.2",
"@storybook/vue3-vite": "^7.0.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/vue": "^6.6.1",
"@storybook/addon-actions": "7.0.2",
"@storybook/addon-essentials": "7.0.2",
"@storybook/addon-interactions": "7.0.2",
"@storybook/addon-links": "7.0.2",
"@storybook/addon-storysource": "7.0.2",
"@storybook/addons": "7.0.2",
"@storybook/blocks": "7.0.2",
"@storybook/core-events": "7.0.2",
"@storybook/jest": "0.1.0",
"@storybook/manager-api": "7.0.2",
"@storybook/preview-api": "7.0.2",
"@storybook/react": "7.0.2",
"@storybook/react-vite": "7.0.2",
"@storybook/testing-library": "0.0.14-next.1",
"@storybook/theming": "7.0.2",
"@storybook/types": "7.0.2",
"@storybook/vue3": "7.0.2",
"@storybook/vue3-vite": "7.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
"@types/estree": "^1.0.0",
"@types/estree": "1.0.0",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2",
"@types/micromatch": "3.1.1",
"@types/node": "18.15.11",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0",
@ -109,32 +110,34 @@
"@types/uuid": "9.0.1",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"@vitest/coverage-c8": "^0.29.8",
"@vue/runtime-core": "3.2.47",
"astring": "^1.8.4",
"chokidar-cli": "^3.0.0",
"chromatic": "^6.17.2",
"astring": "1.8.4",
"chokidar-cli": "3.0.0",
"chromatic": "6.17.3",
"cross-env": "7.0.3",
"cypress": "12.9.0",
"eslint": "8.37.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.10.0",
"fast-glob": "^3.2.12",
"fast-glob": "3.2.12",
"happy-dom": "8.9.0",
"msw": "^1.1.0",
"msw-storybook-addon": "^1.8.0",
"prettier": "^2.8.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"micromatch": "3.1.10",
"msw": "1.2.1",
"msw-storybook-addon": "1.8.0",
"prettier": "2.8.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
"storybook": "^7.0.2",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vitest": "^0.29.8",
"vitest-fetch-mock": "^0.2.2",
"vue-eslint-parser": "9.1.0",
"vite-plugin-turbosnap": "^1.0.1",
"vitest": "0.29.8",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.1.1",
"vue-tsc": "1.2.0"
}
}

View file

@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import { galleryPost } from '../../.storybook/fakes';
import MkGalleryPostPreview from './MkGalleryPostPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkGalleryPostPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkGalleryPostPreview v-bind="props" />',
};
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const links = canvas.getAllByRole('link');
await expect(links).toHaveLength(2);
await expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`);
await expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`);
},
args: {
post: galleryPost(),
},
decorators: [
() => ({
template: '<div style="width:260px"><story /></div>',
}),
],
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;
export const Hover = {
...Default,
async play(context) {
await Default.play(context);
const canvas = within(context.canvasElement);
const links = canvas.getAllByRole('link');
await waitFor(() => userEvent.hover(links[0]));
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;
export const HoverThenUnhover = {
...Default,
async play(context) {
await Hover.play(context);
const canvas = within(context.canvasElement);
const links = canvas.getAllByRole('link');
await waitFor(() => userEvent.unhover(links[0]));
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;
export const Sensitive = {
...Default,
args: {
...Default.args,
post: galleryPost(true),
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;
export const SensitiveHover = {
...Hover,
args: {
...Hover.args,
post: galleryPost(true),
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;
export const SensitiveHoverThenUnhover = {
...HoverThenUnhover,
args: {
...HoverThenUnhover.args,
post: galleryPost(true),
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;

View file

@ -1,7 +1,10 @@
<template>
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover">
<div class="thumbnail">
<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
<ImgWithBlurhash class="img" :hash="post.files[0].blurhash"/>
<Transition>
<ImgWithBlurhash v-if="show" class="img layered" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
</Transition>
</div>
<article>
<header>
@ -15,12 +18,25 @@
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import { computed, ref } from 'vue';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { defaultStore } from '@/store';
const props = defineProps<{
post: any;
post: misskey.entities.GalleryPost;
}>();
const hover = ref(false);
const show = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive || hover.value);
function enterHover(): void {
hover.value = true;
}
function leaveHover(): void {
hover.value = false;
}
</script>
<style lang="scss" scoped>
@ -56,6 +72,21 @@ const props = defineProps<{
width: 100%;
height: 100%;
object-fit: cover;
&.layered {
position: absolute;
top: 0;
&.v-enter-active,
&.v-leave-active {
transition: opacity 0.5s ease;
}
&.v-enter-from,
&.v-leave-to {
opacity: 0;
}
}
}
}

View file

@ -964,7 +964,7 @@ defineExpose({
padding: 0 12px;
line-height: 34px;
font-weight: bold;
border-radius: 4px;
border-radius: 6px;
min-width: 90px;
box-sizing: border-box;
color: var(--fgOnAccent);

View file

@ -22,7 +22,7 @@ export const Default = {
};
},
},
template: '<MkA v-bind="props">Text</MkA>',
template: '<MkA v-bind="props">Misskey</MkA>',
};
},
async play({ canvasElement }) {

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { waitFor } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import MkPageHeader from './MkPageHeader.vue';
export const Empty = {
@ -22,16 +23,16 @@ export const Empty = {
template: '<MkPageHeader v-bind="props" />',
};
},
async play() {
const wait = new Promise((resolve) => setTimeout(resolve, 800));
await waitFor(async () => await wait);
},
args: {
static: true,
tabs: [],
},
parameters: {
layout: 'centered',
chromatic: {
/* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
disableSnapshot: true,
},
},
} satisfies StoryObj<typeof MkPageHeader>;
export const OneTab = {

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/testing-library';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { commonHandlers } from '../../../.storybook/mocks';
@ -30,7 +30,7 @@ export const Default = {
const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/');
await userEvent.hover(a);
await waitFor(() => userEvent.hover(a));
/*
await tick(); // FIXME: wait for network request
const anchors = canvas.getAllByRole<HTMLAnchorElement>('link');
@ -44,7 +44,7 @@ export const Default = {
await expect(icon).toBeInTheDocument();
await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico');
*/
await userEvent.unhover(a);
await waitFor(() => userEvent.unhover(a));
},
args: {
url: 'https://misskey-hub.net/',

View file

@ -217,6 +217,7 @@ const patrons = [
'氷月氷華里',
'Ebise Lutica',
'巣黒るい@リスケモ男の娘VTuber!',
'ふぇいぽむ',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View file

@ -127,6 +127,7 @@ hr {
}
.ti {
width: 1.28em;
vertical-align: -12%;
line-height: 1em;

View file

@ -31,7 +31,7 @@
<button v-click-anime class="item _button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/>
</button>
<div class="post" @click="post">
<div class="post" @click="os.post()">
<MkButton class="button" gradate full rounded>
<i class="ti ti-pencil ti-fw"></i>
</MkButton>
@ -41,93 +41,50 @@
</div>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted } from 'vue';
import { openInstanceMenu } from './_common_/common';
import { host } from '@/config';
import * as os from '@/os';
import { navbarItemDef } from '@/navbar';
import { openAccountMenu, $i } from '@/account';
import { openAccountMenu as openAccountMenu_, $i } from '@/account';
import MkButton from '@/components/MkButton.vue';
import { mainRouter } from '@/router';
import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
MkButton,
},
const WINDOW_THRESHOLD = 1400;
data() {
return {
host: host,
accounts: [],
connection: null,
navbarItemDef: navbarItemDef,
settingsWindowed: false,
defaultStore,
instance,
$i,
i18n,
};
},
computed: {
menu(): string[] {
return defaultStore.state.menu;
},
otherNavItemIndicated(): boolean {
for (const def in this.navbarItemDef) {
if (this.menu.includes(def)) continue;
if (this.navbarItemDef[def].indicated) return true;
}
return false;
},
},
watch: {
'defaultStore.reactiveState.menuDisplay.value'() {
this.calcViewState();
},
},
created() {
window.addEventListener('resize', this.calcViewState);
this.calcViewState();
},
methods: {
openInstanceMenu,
calcViewState() {
this.settingsWindowed = (window.innerWidth > 1400);
},
post() {
os.post();
},
search() {
mainRouter.push('/search');
},
more(ev) {
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
src: ev.currentTarget ?? ev.target,
anchor: { x: 'center', y: 'bottom' },
}, {
}, 'closed');
},
openAccountMenu: (ev) => {
openAccountMenu({
withExtraOperation: true,
}, ev);
},
},
let settingsWindowed = $ref(window.innerWidth > WINDOW_THRESHOLD);
let menu = $ref(defaultStore.state.menu);
// const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
let otherNavItemIndicated = computed<boolean>(() => {
for (const def in navbarItemDef) {
if (menu.includes(def)) continue;
if (navbarItemDef[def].indicated) return true;
}
return false;
});
function more(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
src: ev.currentTarget ?? ev.target,
anchor: { x: 'center', y: 'bottom' },
}, {
}, 'closed');
}
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: true,
}, ev);
}
onMounted(() => {
window.addEventListener('resize', () => {
settingsWindowed = (window.innerWidth >= WINDOW_THRESHOLD);
}, { passive: true });
});
</script>
<style lang="scss" scoped>

View file

@ -3,7 +3,7 @@
<button v-click-anime class="item _button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
</button>
<div class="post" data-cy-open-post-form @click="post">
<div class="post" data-cy-open-post-form @click="os.post">
<MkButton class="button" gradate full rounded>
<i class="ti ti-pencil ti-fw"></i><span v-if="!iconOnly" class="text">{{ i18n.ts.note }}</span>
</MkButton>
@ -40,109 +40,59 @@
</div>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, computed, watch, nextTick } from 'vue';
import { openInstanceMenu } from './_common_/common';
import { host } from '@/config';
// import { host } from '@/config';
import * as os from '@/os';
import { navbarItemDef } from '@/navbar';
import { openAccountMenu, $i } from '@/account';
import { openAccountMenu as openAccountMenu_, $i } from '@/account';
import MkButton from '@/components/MkButton.vue';
import { StickySidebar } from '@/scripts/sticky-sidebar';
import { mainRouter } from '@/router';
// import { StickySidebar } from '@/scripts/sticky-sidebar';
// import { mainRouter } from '@/router';
//import MisskeyLogo from '@assets/client/misskey.svg';
import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
MkButton,
//MisskeyLogo,
},
const WINDOW_THRESHOLD = 1400;
data() {
return {
host: host,
accounts: [],
connection: null,
navbarItemDef: navbarItemDef,
iconOnly: false,
settingsWindowed: false,
defaultStore,
instance,
$i,
i18n,
};
},
computed: {
menu(): string[] {
return this.defaultStore.state.menu;
},
otherNavItemIndicated(): boolean {
for (const def in this.navbarItemDef) {
if (this.menu.includes(def)) continue;
if (this.navbarItemDef[def].indicated) return true;
}
return false;
},
},
watch: {
'defaultStore.reactiveState.menuDisplay.value'() {
this.calcViewState();
},
iconOnly() {
this.$nextTick(() => {
this.$emit('change-view-mode');
});
},
},
created() {
window.addEventListener('resize', this.calcViewState);
this.calcViewState();
},
mounted() {
const sticky = new StickySidebar(this.$el.parentElement, 16);
window.addEventListener('scroll', () => {
sticky.calc(window.scrollY);
}, { passive: true });
},
methods: {
openInstanceMenu,
calcViewState() {
this.iconOnly = (window.innerWidth <= 1400) || (this.defaultStore.state.menuDisplay === 'sideIcon');
this.settingsWindowed = (window.innerWidth > 1400);
},
post() {
os.post();
},
search() {
mainRouter.push('/search');
},
more(ev) {
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
src: ev.currentTarget ?? ev.target,
}, {}, 'closed');
},
openAccountMenu: (ev) => {
openAccountMenu({
withExtraOperation: true,
}, ev);
},
},
const menu = $ref(defaultStore.state.menu);
const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
const otherNavItemIndicated = computed<boolean>(() => {
for (const def in navbarItemDef) {
if (menu.includes(def)) continue;
if (navbarItemDef[def].indicated) return true;
}
return false;
});
let el = $shallowRef<HTMLElement>();
// let accounts = $ref([]);
// let connection = $ref(null);
let iconOnly = $ref(false);
let settingsWindowed = $ref(false);
function calcViewState() {
iconOnly = (window.innerWidth <= WINDOW_THRESHOLD) || (menuDisplay.value === 'sideIcon');
settingsWindowed = (window.innerWidth > WINDOW_THRESHOLD);
}
function more(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
src: ev.currentTarget ?? ev.target,
}, {}, 'closed');
}
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: true,
}, ev);
}
watch(defaultStore.reactiveState.menuDisplay, () => {
calcViewState();
});
</script>
<style lang="scss" scoped>

View file

@ -166,8 +166,6 @@ export type Channels = {
readAllAntennas: () => void;
unreadAntenna: (payload: Antenna) => void;
readAllAnnouncements: () => void;
readAllChannels: () => void;
unreadChannel: (payload: Note['id']) => void;
myTokenRegenerated: () => void;
reversiNoInvites: () => void;
reversiInvited: (payload: FIXME) => void;
@ -1857,12 +1855,6 @@ export type Endpoints = {
req: NoParams;
res: null;
};
'notifications/read': {
req: {
notificationId: Notification_2['id'];
};
res: null;
};
'page-push': {
req: {
pageId: Page['id'];
@ -2361,7 +2353,6 @@ type MeDetailed = UserDetailed & {
hasPendingReceivedFollowRequest: boolean;
hasUnreadAnnouncement: boolean;
hasUnreadAntenna: boolean;
hasUnreadChannel: boolean;
hasUnreadMentions: boolean;
hasUnreadMessagingMessage: boolean;
hasUnreadNotification: boolean;
@ -2618,7 +2609,11 @@ export class Stream extends EventEmitter<StreamEvents> {
// (undocumented)
removeSharedConnectionPool(pool: Pool): void;
// (undocumented)
send(typeOrPayload: any, payload?: any): void;
send(typeOrPayload: string): void;
// (undocumented)
send(typeOrPayload: string, payload: any): void;
// (undocumented)
send(typeOrPayload: Record<string, any> | any[]): void;
// (undocumented)
state: 'initializing' | 'reconnecting' | 'connected';
// (undocumented)
@ -2714,8 +2709,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:595:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:35:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// src/api.types.ts:594:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -24,23 +24,23 @@
"@swc/jest": "0.2.24",
"@types/jest": "29.5.0",
"@types/node": "18.15.11",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"eslint": "8.37.0",
"jest": "^29.5.0",
"jest-fetch-mock": "^3.0.3",
"jest": "29.5.0",
"jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.4.0",
"mock-socket": "9.2.1",
"tsd": "0.28.1",
"typescript": "5.0.2"
"typescript": "5.0.3"
},
"files": [
"built"
],
"dependencies": {
"@swc/cli": "0.1.62",
"@swc/core": "1.3.42",
"@swc/core": "1.3.46",
"eventemitter3": "5.0.0",
"reconnecting-websocket": "^4.4.0"
"reconnecting-websocket": "4.4.0"
}
}

View file

@ -169,14 +169,21 @@ export default class Stream extends EventEmitter<StreamEvents> {
/**
* Send a message to connection
* ! JSONで行われます !
*/
public send(typeOrPayload: any, payload?: any): void {
const data = payload === undefined ? typeOrPayload : {
type: typeOrPayload,
body: payload,
};
public send(typeOrPayload: string): void
public send(typeOrPayload: string, payload: any): void
public send(typeOrPayload: Record<string, any> | any[]): void
public send(typeOrPayload: string | Record<string, any> | any[], payload?: any): void {
if (typeof typeOrPayload === 'string') {
this.stream.send(JSON.stringify({
type: typeOrPayload,
...(payload === undefined ? {} : { body: payload }),
}));
return;
}
this.stream.send(JSON.stringify(data));
this.stream.send(JSON.stringify(typeOrPayload));
}
/**

View file

@ -14,10 +14,10 @@
"misskey-js": "workspace:*"
},
"devDependencies": {
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/parser": "5.57.1",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.37.0",
"eslint-plugin-import": "2.27.5",
"typescript": "5.0.2"
"typescript": "5.0.3"
}
}

View file

@ -59,7 +59,7 @@ export async function findClient() {
type: 'window',
});
for (const c of clients) {
if (c.url.indexOf('?zen') < 0) return c;
if (!new URL(c.url).searchParams.has('zen')) return c;
}
return null;
}