1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-14 21:35:38 +02:00

Merge branch 'develop' into renovate/major-backend-update-dependencies

This commit is contained in:
kakkokari-gtyih
2025-11-25 09:34:21 +09:00
248 changed files with 9569 additions and 3157 deletions

View File

@@ -7,6 +7,7 @@ const base = require('./jest.config.cjs')
module.exports = {
...base,
globalSetup: "<rootDir>/test/jest.setup.unit.cjs",
testMatch: [
"<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts",

View File

@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddChannelMuting1761569941833 {
name = 'AddChannelMuting1761569941833'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "channel_muting" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "channelId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aec842e98f332ebd8e12f85bad6" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_34415e3062ae7a94617496e81c" ON "channel_muting" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_4d534d7177fc59879d942e96d0" ON "channel_muting" ("channelId") `);
await queryRunner.query(`CREATE INDEX "IDX_6dd314e96806b7df65ddadff72" ON "channel_muting" ("expiresAt") `);
await queryRunner.query(`CREATE INDEX "IDX_b96870ed326ccc7fa243970965" ON "channel_muting" ("userId", "channelId") `);
await queryRunner.query(`ALTER TABLE "note" ADD "renoteChannelId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_34415e3062ae7a94617496e81c5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_4d534d7177fc59879d942e96d03" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_4d534d7177fc59879d942e96d03"`);
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_34415e3062ae7a94617496e81c5"`);
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "renoteChannelId"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b96870ed326ccc7fa243970965"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6dd314e96806b7df65ddadff72"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4d534d7177fc59879d942e96d0"`);
await queryRunner.query(`DROP INDEX "public"."IDX_34415e3062ae7a94617496e81c"`);
await queryRunner.query(`DROP TABLE "channel_muting"`);
}
}

View File

@@ -8,6 +8,7 @@
},
"scripts": {
"start": "node ./built/boot/entry.js",
"start:inspect": "node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
@@ -109,7 +110,6 @@
"chalk": "5.6.2",
"chalk-template": "1.1.2",
"chokidar": "4.0.3",
"cli-highlight": "2.1.11",
"color-convert": "3.1.3",
"content-disposition": "1.0.0",
"date-fns": "4.1.0",
@@ -135,7 +135,7 @@
"jsonld": "8.3.3",
"jsrsasign": "11.1.0",
"juice": "11.0.3",
"meilisearch": "0.53.0",
"meilisearch": "0.54.0",
"mfm-js": "0.25.0",
"microformats-parser": "2.0.4",
"mime-types": "3.0.1",
@@ -213,7 +213,7 @@
"@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.15.5",
"@types/pg": "8.15.6",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",

View File

@@ -6,7 +6,7 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { ChannelFollowingsRepository } from '@/models/_.js';
import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js';
import { MiChannel } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
@@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit {
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private idService: IdService,
@@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit {
onModuleInit() {
}
/**
* フォローしているチャンネルの一覧を取得する.
* @param params
* @param [opts]
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
*/
@bindThis
public async list(
params: {
requestUserId: MiUser['id'],
},
opts?: {
idOnly?: boolean;
joinUser?: boolean;
joinBannerFile?: boolean;
},
): Promise<MiChannel[]> {
if (opts?.idOnly) {
const q = this.channelFollowingsRepository.createQueryBuilder('channel_following')
.select('channel_following.followeeId')
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
return q
.getRawMany<{ channel_following_followeeId: string }>()
.then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel)));
} else {
const q = this.channelsRepository.createQueryBuilder('channel')
.innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id')
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
if (opts?.joinUser) {
q.innerJoinAndSelect('channel.user', 'user');
}
if (opts?.joinBannerFile) {
q.leftJoinAndSelect('channel.banner', 'drive_file');
}
return q.getMany();
}
}
@bindThis
public async follow(
requestUser: MiLocalUser,

View File

@@ -0,0 +1,224 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Brackets, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import { RedisKVCache } from '@/misc/cache.js';
@Injectable()
export class ChannelMutingService {
public mutingChannelsCache: RedisKVCache<Set<string>>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelMutingRepository)
private channelMutingRepository: ChannelMutingRepository,
private idService: IdService,
private globalEventService: GlobalEventService,
) {
this.mutingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'channelMutingChannels', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (userId) => this.channelMutingRepository.find({
where: { userId: userId },
select: ['channelId'],
}).then(xs => new Set(xs.map(x => x.channelId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.redisForSub.on('message', this.onMessage);
}
/**
* ミュートしているチャンネルの一覧を取得する.
* @param params
* @param [opts]
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
*/
@bindThis
public async list(
params: {
requestUserId: MiUser['id'],
},
opts?: {
idOnly?: boolean;
joinUser?: boolean;
joinBannerFile?: boolean;
},
): Promise<MiChannel[]> {
if (opts?.idOnly) {
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
.select('channel_muting.channelId')
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
.andWhere(new Brackets(qb => {
qb.where('channel_muting.expiresAt IS NULL')
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
}));
return q
.getRawMany<{ channel_muting_channelId: string }>()
.then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel)));
} else {
const q = this.channelsRepository.createQueryBuilder('channel')
.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id')
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
.andWhere(new Brackets(qb => {
qb.where('channel_muting.expiresAt IS NULL')
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
}));
if (opts?.joinUser) {
q.innerJoinAndSelect('channel.user', 'user');
}
if (opts?.joinBannerFile) {
q.leftJoinAndSelect('channel.banner', 'drive_file');
}
return q.getMany();
}
}
/**
* 期限切れのチャンネルミュート情報を取得する.
*
* @param [opts]
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない).
*/
public async findExpiredMutings(opts?: {
joinUser?: boolean;
joinChannel?: boolean;
}): Promise<MiChannelMuting[]> {
const now = new Date();
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
.where('channel_muting.expiresAt < :now', { now });
if (opts?.joinUser) {
q.innerJoinAndSelect('channel_muting.user', 'user');
}
if (opts?.joinChannel) {
q.leftJoinAndSelect('channel_muting.channel', 'channel');
}
return q.getMany();
}
/**
* 既にミュートされているかどうかをキャッシュから取得する.
* @param params
* @param params.requestUserId
*/
@bindThis
public async isMuted(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
}): Promise<boolean> {
const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId);
return (mutedChannels?.has(params.targetChannelId) ?? false);
}
/**
* チャンネルをミュートする.
* @param params
* @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限.
*/
@bindThis
public async mute(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
expiresAt?: Date | null,
}): Promise<void> {
await this.channelMutingRepository.insert({
id: this.idService.gen(),
userId: params.requestUserId,
channelId: params.targetChannelId,
expiresAt: params.expiresAt,
});
this.globalEventService.publishInternalEvent('muteChannel', {
userId: params.requestUserId,
channelId: params.targetChannelId,
});
}
/**
* チャンネルのミュートを解除する.
* @param params
*/
@bindThis
public async unmute(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
}): Promise<void> {
await this.channelMutingRepository.delete({
userId: params.requestUserId,
channelId: params.targetChannelId,
});
this.globalEventService.publishInternalEvent('unmuteChannel', {
userId: params.requestUserId,
channelId: params.targetChannelId,
});
}
/**
* 期限切れのチャンネルミュート情報を削除する.
*/
@bindThis
public async eraseExpiredMutings(): Promise<void> {
const expiredMutings = await this.findExpiredMutings();
await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) });
const userIds = [...new Set(expiredMutings.map(x => x.userId))];
for (const userId of userIds) {
this.mutingChannelsCache.refresh(userId).then();
}
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'muteChannel': {
this.mutingChannelsCache.refresh(body.userId).then();
break;
}
case 'unmuteChannel': {
this.mutingChannelsCache.delete(body.userId).then();
break;
}
}
}
}
@bindThis
public dispose(): void {
this.mutingChannelsCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -15,6 +15,7 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
@@ -223,6 +224,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService };
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
@@ -375,6 +377,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
ChannelMutingService,
ChatService,
RegistryApiService,
ReversiService,
@@ -523,6 +526,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$ChannelMutingService,
$ChatService,
$RegistryApiService,
$ReversiService,
@@ -672,6 +676,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
ChannelMutingService,
ChatService,
RegistryApiService,
ReversiService,
@@ -818,6 +823,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$ChannelMutingService,
$ChatService,
$RegistryApiService,
$ReversiService,

View File

@@ -19,6 +19,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { isChannelRelated } from '@/misc/is-channel-related.js';
type NoteFilter = (note: MiNote) => boolean;
@@ -35,6 +37,7 @@ type TimelineOptions = {
ignoreAuthorFromBlock?: boolean;
ignoreAuthorFromMute?: boolean;
ignoreAuthorFromInstanceBlock?: boolean;
ignoreAuthorChannelFromMute?: boolean;
excludeNoFiles?: boolean;
excludeReplies?: boolean;
excludePureRenotes: boolean;
@@ -55,6 +58,7 @@ export class FanoutTimelineEndpointService {
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
private utilityService: UtilityService,
private channelMutingService: ChannelMutingService,
) {
}
@@ -111,11 +115,13 @@ export class FanoutTimelineEndpointService {
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
userMutedChannels,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(ps.me.id),
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
this.cacheService.userBlockedCache.fetch(ps.me.id),
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
this.channelMutingService.mutingChannelsCache.fetch(me.id),
]);
const parentFilter = filter;
@@ -126,6 +132,7 @@ export class FanoutTimelineEndpointService {
if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
if (isChannelRelated(note, userMutedChannels, ps.ignoreAuthorChannelFromMute)) return false;
return parentFilter(note);
};

View File

@@ -255,6 +255,8 @@ export interface InternalEventTypes {
metaUpdated: { before?: MiMeta; after: MiMeta; };
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
updateUserProfile: MiUserProfile;
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };

View File

@@ -604,6 +604,7 @@ export class NoteCreateService implements OnApplicationShutdown {
replyUserHost: data.reply ? data.reply.userHost : null,
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
renoteChannelId: data.renote ? data.renote.channelId : null,
userHost: user.host,
});

View File

@@ -29,7 +29,7 @@ export interface PageBody {
variables: Array<Record<string, any>>;
script: string;
eyeCatchingImage?: MiDriveFile | null;
font: string;
font: 'serif' | 'sans-serif';
alignCenter: boolean;
hideTitleWhenPinned: boolean;
}
@@ -141,7 +141,7 @@ export class PageService {
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
});
console.log("page.content", page.content);
console.log('page.content', page.content);
if (body.content != null) {
const beforeReferencedNotes = this.collectReferencedNotes(page.content);

View File

@@ -133,6 +133,7 @@ export class UtilityService {
@bindThis
public isFederationAllowedHost(host: string): boolean {
if (this.isSelfHost(host)) return true;
if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;
if (this.isBlockedHost(this.meta.blockedHosts, host)) return false;

View File

@@ -106,6 +106,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
renoteChannelId: null,
...override,
};
}

View File

@@ -4,36 +4,40 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js';
import type {
ChannelFavoritesRepository,
ChannelFollowingsRepository, ChannelMutingRepository,
ChannelsRepository,
DriveFilesRepository,
MiDriveFile,
MiNote,
NotesRepository,
} from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiChannel } from '@/models/Channel.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { NoteEntityService } from './NoteEntityService.js';
import { In } from 'typeorm';
@Injectable()
export class ChannelEntityService {
constructor(
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.channelFavoritesRepository)
private channelFavoritesRepository: ChannelFavoritesRepository,
@Inject(DI.channelMutingRepository)
private channelMutingRepository: ChannelMutingRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private noteEntityService: NoteEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
@@ -45,31 +49,59 @@ export class ChannelEntityService {
src: MiChannel['id'] | MiChannel,
me?: { id: MiUser['id'] } | null | undefined,
detailed?: boolean,
opts?: {
bannerFiles?: Map<MiDriveFile['id'], MiDriveFile>;
followings?: Set<MiChannel['id']>;
favorites?: Set<MiChannel['id']>;
muting?: Set<MiChannel['id']>;
pinnedNotes?: Map<MiNote['id'], MiNote>;
},
): Promise<Packed<'Channel'>> {
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
const meId = me ? me.id : null;
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
let bannerFile: MiDriveFile | null = null;
if (channel.bannerId) {
bannerFile = opts?.bannerFiles?.get(channel.bannerId)
?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId });
}
const isFollowing = meId ? await this.channelFollowingsRepository.exists({
where: {
followerId: meId,
followeeId: channel.id,
},
}) : false;
let isFollowing = false;
let isFavorited = false;
let isMuting = false;
if (me) {
isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({
where: {
followerId: me.id,
followeeId: channel.id,
},
});
const isFavorited = meId ? await this.channelFavoritesRepository.exists({
where: {
userId: meId,
channelId: channel.id,
},
}) : false;
isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({
where: {
userId: me.id,
channelId: channel.id,
},
});
const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
where: {
id: In(channel.pinnedNoteIds),
},
}) : [];
isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({
where: {
userId: me.id,
channelId: channel.id,
},
});
}
const pinnedNotes = Array.of<MiNote>();
if (channel.pinnedNoteIds.length > 0) {
pinnedNotes.push(
...(
opts?.pinnedNotes
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null)
: await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) })
),
);
}
return {
id: channel.id,
@@ -78,7 +110,8 @@ export class ChannelEntityService {
name: channel.name,
description: channel.description,
userId: channel.userId,
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null,
bannerId: channel.bannerId,
pinnedNoteIds: channel.pinnedNoteIds,
color: channel.color,
isArchived: channel.isArchived,
@@ -90,6 +123,7 @@ export class ChannelEntityService {
...(me ? {
isFollowing,
isFavorited,
isMuting,
hasUnreadNote: false, // 後方互換性のため
} : {}),
@@ -98,5 +132,72 @@ export class ChannelEntityService {
} : {}),
};
}
@bindThis
public async packMany(
src: MiChannel['id'][] | MiChannel[],
me?: { id: MiUser['id'] } | null | undefined,
detailed?: boolean,
): Promise<Packed<'Channel'>[]> {
// IDのみの要素がある場合、DBからオブジェクトを取得して補う
const channels = src.filter(it => typeof it === 'object') as MiChannel[];
channels.push(
...(await this.channelsRepository.find({
where: {
id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]),
},
})),
);
channels.sort((a, b) => a.id.localeCompare(b.id));
const bannerFiles = await this.driveFilesRepository
.findBy({
id: In(channels.map(it => it.bannerId).filter(it => it != null)),
})
.then(it => new Map(it.map(it => [it.id, it])));
const followings = me
? await this.channelFollowingsRepository
.findBy({
followerId: me.id,
followeeId: In(channels.map(it => it.id)),
})
.then(it => new Set(it.map(it => it.followeeId)))
: new Set<MiChannel['id']>();
const favorites = me
? await this.channelFavoritesRepository
.findBy({
userId: me.id,
channelId: In(channels.map(it => it.id)),
})
.then(it => new Set(it.map(it => it.channelId)))
: new Set<MiChannel['id']>();
const muting = me
? await this.channelMutingRepository
.findBy({
userId: me.id,
channelId: In(channels.map(it => it.id)),
})
.then(it => new Set(it.map(it => it.channelId)))
: new Set<MiChannel['id']>();
const pinnedNotes = await this.notesRepository
.find({
where: {
id: In(channels.flatMap(it => it.pinnedNoteIds)),
},
})
.then(it => new Map(it.map(it => [it.id, it])));
return Promise.all(channels.map(it => this.pack(it, me, detailed, {
bannerFiles,
followings,
favorites,
muting,
pinnedNotes,
})));
}
}

View File

@@ -70,6 +70,7 @@ export const DI = {
channelsRepository: Symbol('channelsRepository'),
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
channelFavoritesRepository: Symbol('channelFavoritesRepository'),
channelMutingRepository: Symbol('channelMutingRepository'),
registryItemsRepository: Symbol('registryItemsRepository'),
webhooksRepository: Symbol('webhooksRepository'),
systemWebhooksRepository: Symbol('systemWebhooksRepository'),

View File

@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MiNote } from '@/models/Note.js';
import { Packed } from '@/misc/json-schema.js';
/**
* {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。
* 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。
*
* @param note 確認対象のノート
* @param channelIds 確認対象のチャンネルID一覧
* @param ignoreAuthor trueの場合、ートの所属チャンネルが{@link channelIds}に含まれていても無視しますデフォルトはfalse
*/
export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set<string>, ignoreAuthor = false): boolean {
// ートの所属チャンネルが確認対象のチャンネルID一覧に含まれている場合
if (!ignoreAuthor && note.channelId && channelIds.has(note.channelId)) {
return true;
}
const renoteChannelId = note.renote?.channelId;
if (renoteChannelId != null && renoteChannelId !== note.channelId && channelIds.has(renoteChannelId)) {
return true;
}
// NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので)
return false;
}

View File

@@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiChannel } from './Channel.js';
@Entity('channel_muting')
@Index(['userId', 'channelId'], {})
export class MiChannelMuting {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Index()
@Column({
...id(),
})
public channelId: MiChannel['id'];
@ManyToOne(type => MiChannel, {
onDelete: 'CASCADE',
})
@JoinColumn()
public channel: MiChannel | null;
@Index()
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
}

View File

@@ -248,6 +248,14 @@ export class MiNote {
})
public renoteUserHost: string | null;
@Column({
...id(),
nullable: true,
comment: '[Denormalized]',
})
public renoteChannelId: MiChannel['id'] | null;
//#endregion
constructor(data: Partial<MiNote>) {
if (data == null) return;

View File

@@ -47,7 +47,7 @@ export class MiPage {
@Column('varchar', {
length: 32,
})
public font: string;
public font: 'serif' | 'sans-serif';
@Index()
@Column({

View File

@@ -21,6 +21,7 @@ import {
MiChannel,
MiChannelFavorite,
MiChannelFollowing,
MiChannelMuting,
MiClip,
MiClipFavorite,
MiClipNote,
@@ -429,6 +430,12 @@ const $channelFavoritesRepository: Provider = {
inject: [DI.db],
};
const $channelMutingRepository: Provider = {
provide: DI.channelMutingRepository,
useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository<MiChannelMuting>),
inject: [DI.db],
};
const $registryItemsRepository: Provider = {
provide: DI.registryItemsRepository,
useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository<MiRegistryItem>),
@@ -597,6 +604,7 @@ const $reversiGamesRepository: Provider = {
$channelsRepository,
$channelFollowingsRepository,
$channelFavoritesRepository,
$channelMutingRepository,
$registryItemsRepository,
$webhooksRepository,
$systemWebhooksRepository,
@@ -674,6 +682,7 @@ const $reversiGamesRepository: Provider = {
$channelsRepository,
$channelFollowingsRepository,
$channelFavoritesRepository,
$channelMutingRepository,
$registryItemsRepository,
$webhooksRepository,
$systemWebhooksRepository,

View File

@@ -5,18 +5,9 @@
import {
FindOneOptions,
InsertQueryBuilder,
ObjectLiteral,
QueryRunner,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
import {
RawSqlResultsToEntityTransformer,
} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAccessToken } from '@/models/AccessToken.js';
@@ -32,6 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiChannel } from '@/models/Channel.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChannelMuting } from "@/models/ChannelMuting.js";
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
@@ -95,66 +87,12 @@ import { MiWebhook } from '@/models/Webhook.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository<T extends ObjectLiteral> {
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
insertOne(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>): Promise<T>;
insertOneImpl(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>, queryRunner?: QueryRunner): Promise<T>;
selectAliasColumnNames(this: Repository<T> & MiRepository<T>, queryBuilder: InsertQueryBuilder<T>, builder: SelectQueryBuilder<T>): void;
}
export const miRepository = {
createTableColumnNames() {
return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName);
},
async insertOne(entity, findOptions?) {
const opt = this.manager.connection.options as PostgresConnectionOptions;
if (opt.replication) {
const queryRunner = this.manager.connection.createQueryRunner('master');
try {
return this.insertOneImpl(entity, findOptions, queryRunner);
} finally {
await queryRunner.release();
}
} else {
return this.insertOneImpl(entity, findOptions);
}
},
async insertOneImpl(entity, findOptions?, queryRunner?) {
// ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ----
const queryBuilder = this.createQueryBuilder().insert().values(entity);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mainAlias = queryBuilder.expressionMap.mainAlias!;
const name = mainAlias.name;
mainAlias.name = 't';
const columnNames = this.createTableColumnNames();
queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2));
// ---- 共通テーブル式(CTE)から結果を取得 ----
const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
builder.expressionMap.mainAlias!.tablePath = 'cte';
this.selectAliasColumnNames(queryBuilder, builder);
if (findOptions) {
builder.setFindOptions(findOptions);
}
const raw = await builder.execute();
mainAlias.name = name;
const relationId = await new RelationIdLoader(builder.connection, this.queryRunner, builder.expressionMap.relationIdAttributes).load(raw);
const relationCount = await new RelationCountLoader(builder.connection, this.queryRunner, builder.expressionMap.relationCountAttributes).load(raw);
const result = new RawSqlResultsToEntityTransformer(builder.expressionMap, builder.connection.driver, relationId, relationCount, this.queryRunner).transform(raw, mainAlias);
return result[0];
},
selectAliasColumnNames(queryBuilder, builder) {
let selectOrAddSelect = (selection: string, selectionAliasName?: string) => {
selectOrAddSelect = (selection, selectionAliasName) => builder.addSelect(selection, selectionAliasName);
return builder.select(selection, selectionAliasName);
};
for (const columnName of this.createTableColumnNames()) {
selectOrAddSelect(`${builder.alias}.${columnName}`, `${builder.alias}_${columnName}`);
}
return await this.insert(entity).then(x => this.findOneOrFail({ where: x.identifiers[0], ...findOptions }));
},
} satisfies MiRepository<ObjectLiteral>;
@@ -172,6 +110,7 @@ export {
MiBlocking,
MiChannelFollowing,
MiChannelFavorite,
MiChannelMuting,
MiClip,
MiClipNote,
MiClipFavorite,
@@ -251,6 +190,7 @@ export type AuthSessionsRepository = Repository<MiAuthSession> & MiRepository<Mi
export type BlockingsRepository = Repository<MiBlocking> & MiRepository<MiBlocking>;
export type ChannelFollowingsRepository = Repository<MiChannelFollowing> & MiRepository<MiChannelFollowing>;
export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepository<MiChannelFavorite>;
export type ChannelMutingRepository = Repository<MiChannelMuting> & MiRepository<MiChannelMuting>;
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;

View File

@@ -40,6 +40,11 @@ export const packedChannelSchema = {
format: 'url',
nullable: true, optional: false,
},
bannerId: {
type: 'string',
nullable: true, optional: false,
format: 'id',
},
pinnedNoteIds: {
type: 'array',
nullable: false, optional: false,
@@ -80,6 +85,10 @@ export const packedChannelSchema = {
type: 'boolean',
optional: true, nullable: false,
},
isMuting: {
type: 'boolean',
optional: true, nullable: false,
},
pinnedNotes: {
type: 'array',
optional: true, nullable: false,

View File

@@ -174,6 +174,7 @@ export const packedPageSchema = {
font: {
type: 'string',
optional: false, nullable: false,
enum: ['serif', 'sans-serif'],
},
script: {
type: 'string',

View File

@@ -6,7 +6,6 @@
// https://github.com/typeorm/typeorm/issues/2400
import pg from 'pg';
import { DataSource, Logger, type QueryRunner } from 'typeorm';
import * as highlight from 'cli-highlight';
import { entities as charts } from '@/core/chart/entities.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
@@ -25,6 +24,7 @@ import { MiAuthSession } from '@/models/AuthSession.js';
import { MiBlocking } from '@/models/Blocking.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelMuting } from '@/models/ChannelMuting.js';
import { MiClip } from '@/models/Clip.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiClipFavorite } from '@/models/ClipFavorite.js';
@@ -100,12 +100,6 @@ export type LoggerProps = {
printReplicationMode?: boolean,
};
function highlightSql(sql: string) {
return highlight.highlight(sql, {
language: 'sql', ignoreIllegals: true,
});
}
function truncateSql(sql: string) {
return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql;
}
@@ -131,7 +125,7 @@ class MyCustomLogger implements Logger {
modded = truncateSql(modded);
}
return highlightSql(modded);
return modded;
}
@bindThis
@@ -239,6 +233,7 @@ export const entities = [
MiChannel,
MiChannelFollowing,
MiChannelFavorite,
MiChannelMuting,
MiRegistryItem,
MiAd,
MiPasswordResetRequest,

View File

@@ -4,14 +4,13 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MutingsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { UserMutingService } from '@/core/UserMutingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
@Injectable()
export class CheckExpiredMutingsProcessorService {
@@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService {
private mutingsRepository: MutingsRepository,
private userMutingService: UserMutingService,
private channelMutingService: ChannelMutingService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
@@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService {
await this.userMutingService.unmute(expired);
}
await this.channelMutingService.eraseExpiredMutings();
this.logger.succ('All expired mutings checked.');
}
}

View File

@@ -51,6 +51,17 @@ export class CleanRemoteNotesProcessorService {
skipped: boolean;
transientErrors: number;
}> {
const getConfig = () => {
return {
enabled: this.meta.enableRemoteNotesCleaning,
maxDuration: this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000, // Convert minutes to milliseconds
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
newestLimit: this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)),
};
};
const initialConfig = getConfig();
if (!this.meta.enableRemoteNotesCleaning) {
this.logger.info('Remote notes cleaning is disabled, skipping...');
return {
@@ -64,13 +75,9 @@ export class CleanRemoteNotesProcessorService {
this.logger.info('cleaning remote notes...');
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
const startAt = Date.now();
//#region queries
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
// The condition for removing the notes.
// The note must be:
@@ -92,7 +99,7 @@ export class CleanRemoteNotesProcessorService {
const minId = (await this.notesRepository.createQueryBuilder('note')
.select('MIN(note.id)', 'minId')
.where({
id: LessThan(newestLimit),
id: LessThan(initialConfig.newestLimit),
userHost: Not(IsNull()),
replyId: IsNull(),
renoteId: IsNull(),
@@ -155,12 +162,12 @@ export class CleanRemoteNotesProcessorService {
// | fff | fff | TRUE |
// | ggg | ggg | FALSE |
//
const candidateNotesQuery = this.db.createQueryBuilder()
const candidateNotesQuery = ({ limit }: { limit: number }) => this.db.createQueryBuilder()
.select(`"${candidateNotesCteName}"."id"`, 'id')
.addSelect('unremovable."id" IS NULL', 'isRemovable')
.addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
.addCommonTableExpression(
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(limit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
candidateNotesCteName,
{ recursive: true },
)
@@ -178,6 +185,11 @@ export class CleanRemoteNotesProcessorService {
let lowThroughputWarned = false;
let transientErrors = 0;
for (;;) {
const { enabled, maxDuration, newestLimit } = getConfig();
if (!enabled) {
this.logger.info('Remote notes cleaning is disabled, processing stopped...');
break;
}
//#region check time
const batchBeginAt = Date.now();
@@ -205,13 +217,38 @@ export class CleanRemoteNotesProcessorService {
let noteIds = null;
try {
noteIds = await candidateNotesQuery.setParameters(
noteIds = await candidateNotesQuery({ limit: currentLimit }).setParameters(
{ newestLimit, cursorLeft },
).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
} catch (e) {
if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') {
// Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again
// continuous failures will eventually converge to currentLimit == minimumLimit and then throw
if (e instanceof QueryFailedError && e.driverError?.code === '57014') {
// Statement timeout (maybe suddenly hit a large note tree), if possible, reduce the limit and try again
// if not possible, skip the current batch of notes and find the next root note
if (currentLimit <= minimumLimit) {
job.log('Local note tree complexity is too high, finding next root note...');
const idWindow = await this.notesRepository.createQueryBuilder('note')
.select('id')
.where('note.id > :cursorLeft')
.andWhere(removalCriteria)
.andWhere({ replyId: IsNull(), renoteId: IsNull() })
.orderBy('note.id', 'ASC')
.limit(minimumLimit + 1)
.setParameters({ cursorLeft, newestLimit })
.getRawMany<{ id?: MiNote['id'] }>();
job.log(`Skipped note IDs: ${idWindow.slice(0, minimumLimit).map(id => id.id).join(', ')}`);
const lastId = idWindow.at(minimumLimit)?.id;
if (!lastId) {
job.log('No more notes to clean.');
break;
}
cursorLeft = lastId;
continue;
}
currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
continue;
}

View File

@@ -15,6 +15,7 @@ import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
@@ -39,6 +40,7 @@ export class StreamingApiServerService {
private notificationService: NotificationService,
private usersService: UserService,
private channelFollowingService: ChannelFollowingService,
private channelMutingService: ChannelMutingService,
) {
}
@@ -97,6 +99,7 @@ export class StreamingApiServerService {
this.notificationService,
this.cacheService,
this.channelFollowingService,
this.channelMutingService,
user, app,
);

View File

@@ -143,6 +143,9 @@ export * as 'channels/timeline' from './endpoints/channels/timeline.js';
export * as 'channels/unfavorite' from './endpoints/channels/unfavorite.js';
export * as 'channels/unfollow' from './endpoints/channels/unfollow.js';
export * as 'channels/update' from './endpoints/channels/update.js';
export * as 'channels/mute/create' from './endpoints/channels/mute/create.js';
export * as 'channels/mute/delete' from './endpoints/channels/mute/delete.js';
export * as 'channels/mute/list' from './endpoints/channels/mute/list.js';
export * as 'charts/active-users' from './endpoints/charts/active-users.js';
export * as 'charts/ap-request' from './endpoints/charts/ap-request.js';
export * as 'charts/drive' from './endpoints/charts/drive.js';

View File

@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
@@ -14,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -69,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -108,6 +111,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
// -- ミュートされたチャンネル対策
const mutingChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id));
if (mutingChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.channelId IS NULL');
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}));
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteChannelId IS NULL');
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}));
}
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255

View File

@@ -46,7 +46,7 @@ export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 128 },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
description: { type: 'string', nullable: true, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
color: { type: 'string', minLength: 1, maxLength: 16 },
isSensitive: { type: 'boolean', nullable: true },

View File

@@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
export const meta = {
tags: ['channels', 'mute'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
errors: {
noSuchChannel: {
message: 'No such Channel.',
code: 'NO_SUCH_CHANNEL',
id: '7174361e-d58f-31d6-2e7c-6fb830786a3f',
},
alreadyMuting: {
message: 'You are already muting that user.',
code: 'ALREADY_MUTING_CHANNEL',
id: '5a251978-769a-da44-3e89-3931e43bb592',
},
expiresAtIsPast: {
message: 'Cannot set past date to "expiresAt".',
code: 'EXPIRES_AT_IS_PAST',
id: '42b32236-df2c-a45f-fdbf-def67268f749',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
channelId: { type: 'string', format: 'misskey:id' },
expiresAt: {
type: 'integer',
nullable: true,
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
},
},
required: ['channelId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
// Check if exists the channel
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
if (!targetChannel) {
throw new ApiError(meta.errors.noSuchChannel);
}
// Check if already muting
const exist = await this.channelMutingService.isMuted({
requestUserId: me.id,
targetChannelId: targetChannel.id,
});
if (exist) {
throw new ApiError(meta.errors.alreadyMuting);
}
// Check if expiresAt is past
if (ps.expiresAt && ps.expiresAt <= Date.now()) {
throw new ApiError(meta.errors.expiresAtIsPast);
}
await this.channelMutingService.mute({
requestUserId: me.id,
targetChannelId: targetChannel.id,
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
});
});
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['channels', 'mute'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
errors: {
noSuchChannel: {
message: 'No such Channel.',
code: 'NO_SUCH_CHANNEL',
id: 'e7998769-6e94-d9c2-6b8f-94a527314aba',
},
notMuting: {
message: 'You are not muting that channel.',
code: 'NOT_MUTING_CHANNEL',
id: '14d55962-6ea8-d990-1333-d6bef78dc2ab',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
channelId: { type: 'string', format: 'misskey:id' },
},
required: ['channelId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
// Check if exists the channel
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
if (!targetChannel) {
throw new ApiError(meta.errors.noSuchChannel);
}
// Check muting
const exist = await this.channelMutingService.isMuted({
requestUserId: me.id,
targetChannelId: targetChannel.id,
});
if (!exist) {
throw new ApiError(meta.errors.notMuting);
}
await this.channelMutingService.unmute({
requestUserId: me.id,
targetChannelId: targetChannel.id,
});
});
}
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
export const meta = {
tags: ['channels', 'mute'],
requireCredential: true,
prohibitMoved: true,
kind: 'read:channels',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Channel',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private channelMutingService: ChannelMutingService,
private channelEntityService: ChannelEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const mutings = await this.channelMutingService.list({
requestUserId: me.id,
});
return await this.channelEntityService.packMany(mutings, me);
});
}
}

View File

@@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { MiLocalUser } from '@/models/User.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -70,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private activeUsersChart: ActiveUsersChart,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -98,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
useDbFallback: true,
redisTimelines: [`channelTimeline:${channel.id}`],
excludePureRenotes: false,
ignoreAuthorChannelFromMute: true,
dbFallback: async (untilId, sinceId, limit) => {
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
},
@@ -122,6 +125,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBaseNoteFilteringQuery(query, me);
if (me) {
const mutingChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id).filter(x => x !== ps.channelId));
if (mutingChannelIds.length > 0) {
query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}
}
//#endregion
return await query.limit(ps.limit).getMany();

View File

@@ -50,7 +50,7 @@ export const paramDef = {
properties: {
channelId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 128 },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
description: { type: 'string', nullable: true, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
isArchived: { type: 'boolean', nullable: true },
pinnedNoteIds: {

View File

@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import type { ClipFavoritesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
@@ -30,6 +31,11 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: [],
} as const;
@@ -40,14 +46,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository,
private queryService: QueryService,
private clipEntityService: ClipEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.clipFavoritesRepository.createQueryBuilder('favorite')
const query = this.queryService.makePaginationQuery(this.clipFavoritesRepository.createQueryBuilder('favorite'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('favorite.userId = :meId', { meId: me.id })
.leftJoinAndSelect('favorite.clip', 'clip');
const favorites = await query
.limit(ps.limit)
.getMany();
return this.clipEntityService.packMany(favorites.map(x => x.clip!), me);

View File

@@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -46,7 +48,7 @@ export const meta = {
bothWithRepliesAndWithFiles: {
message: 'Specifying both withReplies and withFiles is not supported',
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f'
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f',
},
},
} as const;
@@ -79,9 +81,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private noteEntityService: NoteEntityService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
@@ -89,6 +88,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService,
private queryService: QueryService,
private userFollowingService: UserFollowingService,
private channelMutingService: ChannelMutingService,
private channelFollowingService: ChannelFollowingService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -196,11 +197,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withReplies: boolean,
}, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({
where: {
followerId: me.id,
},
});
const mutingChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id));
const followingChannelIds = await this.channelFollowingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
@@ -219,9 +222,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followingChannels.length > 0) {
const followingChannelIds = followingChannels.map(x => x.followeeId);
if (followingChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
qb.orWhere('note.channelId IS NULL');
@@ -230,6 +231,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('note.channelId IS NULL');
}
if (mutingChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteChannelId IS NULL');
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}));
}
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb

View File

@@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -76,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -157,7 +159,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const mutedChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id));
if (mutedChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteChannelId IS NULL')
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
}));
}
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View File

@@ -5,7 +5,7 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
import type { NotesRepository, MiMeta } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
export const meta = {
tags: ['notes'],
@@ -61,15 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private noteEntityService: NoteEntityService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private userFollowingService: UserFollowingService,
private channelMutingService: ChannelMutingService,
private channelFollowingService: ChannelFollowingService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -140,11 +141,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({
where: {
followerId: me.id,
},
});
const mutingChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id));
const followingChannelIds = await this.channelFollowingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
@@ -154,15 +157,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0 && followingChannels.length > 0) {
if (followees.length > 0 && followingChannelIds.length > 0) {
// ユーザー・チャンネルともにフォローあり
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb2 => {
qb2
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
.andWhere('note.channelId IS NULL');
}))
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
@@ -170,22 +172,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else if (followees.length > 0) {
// ユーザーフォローのみ(チャンネルフォローなし)
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else if (followingChannels.length > 0) {
// チャンネルフォローのみ(ユーザーフォローなし)
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.andWhere('note.channelId IS NULL')
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
if (mutingChannelIds.length > 0) {
qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}
}));
} else if (followingChannelIds.length > 0) {
// チャンネルフォローのみ(ユーザーフォローなし)
query.andWhere(new Brackets(qb => {
qb
// renoteChannelIdは見る必要が無い
// ・HTLに流れてくるチャンネルフォローしているチャンネル
// ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリートした場合のみ
// つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
.orWhere('note.userId = :meId', { meId: me.id });
}));
} else {
// フォローなし
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId = :meId', { meId: me.id });
query.andWhere(new Brackets(qb => {
qb
.andWhere('note.channelId IS NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}));
}
query.andWhere(new Brackets(qb => {

View File

@@ -95,7 +95,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
const params = new URLSearchParams();
params.append('auth_key', this.serverSettings.deeplAuthKey);
params.append('text', note.text);
params.append('target_lang', targetLang);
@@ -104,6 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const res = await this.httpRequestService.send(endpoint, {
method: 'POST',
headers: {
'Authorization': `DeepL-Auth-Key ${this.serverSettings.deeplAuthKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, */*',
},

View File

@@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -84,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -187,6 +189,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBaseNoteFilteringQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
// -- ミュートされたチャンネルのリノート対策
const mutedChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id));
if (mutedChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteChannelId IS NULL')
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
}));
}
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });

View File

@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, RolesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
@@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -68,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -101,6 +104,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
// -- ミュートされたチャンネル対策
const mutingChannelIds = await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id));
if (mutingChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.channelId IS NULL');
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}));
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteChannelId IS NULL');
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}));
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);

View File

@@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { ApiError } from '@/server/api/error.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
export const meta = {
tags: ['users', 'notes'],
@@ -77,12 +78,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private cacheService: CacheService,
private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -165,6 +166,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) {
const mutingChannelIds = me
? await this.channelMutingService
.list({ requestUserId: me.id }, { idOnly: true })
.then(x => x.map(x => x.id))
: [];
const isSelf = me && (me.id === ps.userId);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
@@ -177,14 +183,30 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.withChannelNotes) {
if (!isSelf) query.andWhere(new Brackets(qb => {
qb.orWhere('note.channelId IS NULL');
qb.orWhere('channel.isSensitive = false');
query.andWhere(new Brackets(qb => {
if (mutingChannelIds.length > 0) {
qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds });
}
if (!isSelf) {
qb.andWhere(new Brackets(qb2 => {
qb2.orWhere('note.channelId IS NULL');
qb2.orWhere('channel.isSensitive = false');
}));
}
}));
} else {
query.andWhere('note.channelId IS NULL');
}
// -- ミュートされたチャンネルのリノート対策
if (mutingChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteChannelId IS NULL');
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
}));
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me, {
excludeAuthor: true,

View File

@@ -11,8 +11,9 @@ import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import type { ChannelsService } from './ChannelsService.js';
@@ -35,6 +36,7 @@ export default class Connection {
public userProfile: MiUserProfile | null = null;
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public followingChannels: Set<string> = new Set();
public mutingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
@@ -46,7 +48,7 @@ export default class Connection {
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
private channelMutingService: ChannelMutingService,
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
) {
@@ -57,10 +59,19 @@ export default class Connection {
@bindThis
public async fetch() {
if (this.user == null) return;
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
const [
userProfile,
following,
followingChannels,
mutingChannels,
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
userIdsWhoMeMutingRenotes,
] = await Promise.all([
this.cacheService.userProfileCache.fetch(this.user.id),
this.cacheService.userFollowingsCache.fetch(this.user.id),
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
this.channelMutingService.mutingChannelsCache.fetch(this.user.id),
this.cacheService.userMutingsCache.fetch(this.user.id),
this.cacheService.userBlockedCache.fetch(this.user.id),
this.cacheService.renoteMutingsCache.fetch(this.user.id),
@@ -68,6 +79,7 @@ export default class Connection {
this.userProfile = userProfile;
this.following = following;
this.followingChannels = followingChannels;
this.mutingChannels = mutingChannels;
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;

View File

@@ -6,7 +6,8 @@
import { bindThis } from '@/decorators.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import { isChannelRelated } from '@/misc/is-channel-related.js';
import type { Packed } from '@/misc/json-schema.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import type Connection from './Connection.js';
@@ -55,6 +56,10 @@ export default abstract class Channel {
return this.connection.followingChannels;
}
protected get mutingChannels() {
return this.connection.mutingChannels;
}
protected get subscriber() {
return this.connection.subscriber;
}
@@ -74,6 +79,9 @@ export default abstract class Channel {
// 流れてきたNoteがリートをミュートしてるユーザが行ったもの
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
// 流れてきたNoteがミュートしているチャンネルと関わる
if (isChannelRelated(note, this.mutingChannels)) return true;
return false;
}

View File

@@ -8,6 +8,8 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@@ -19,7 +21,6 @@ class ChannelChannel extends Channel {
constructor(
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
@@ -40,6 +41,10 @@ class ChannelChannel extends Channel {
private async onNote(note: Packed<'Note'>) {
if (note.channelId !== this.channelId) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
@@ -52,6 +57,35 @@ class ChannelChannel extends Channel {
this.send('note', note);
}
/*
* ミュートとブロックされてるを処理する
*/
protected override isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return true;
// 流れてきたNoteがミュートしているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
// 流れてきたNoteがブロックされているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true;
// 流れてきたNoteがリートをミュートしてるユーザが行ったもの
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
// このソケットで見ているチャンネルがミュートされていたとしても、チャンネルを直接見ている以上は流すようにしたい
// ただし、他のミュートしているチャンネルは流さないようにもしたい
// ート自体のチャンネルIDはonNoteでチェックしているので、ここではリートのチャンネルIDをチェックする
if (
(note.renote) &&
(note.renote.channelId !== this.channelId) &&
(note.renote.channelId && this.mutingChannels.has(note.renote.channelId))
) {
return true;
}
return false;
}
@bindThis
public dispose() {
// Unsubscribe events

View File

@@ -44,7 +44,10 @@ class HomeTimelineChannel extends Channel {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.channelId) {
if (!this.followingChannels.has(note.channelId)) return;
// そのチャンネルをフォローしていない
if (!this.followingChannels.has(note.channelId)) {
return;
}
} else {
// その投稿のユーザーをフォローしていなかったら弾く
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;

View File

@@ -53,16 +53,25 @@ class HybridTimelineChannel extends Channel {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
// チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
// フォローしているチャンネルの投稿 の場合だけ
if (!(
(note.channelId == null && isMe) ||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.has(note.channelId))
)) return;
if (!note.channelId) {
// 以下の条件に該当するートのみ後続処理に通すので、以下のif文は該当しないートをすべて弾くようにする
// - 自分自身の投稿
// - その投稿のユーザーをフォローしている
// - 全体公開のローカルの投稿
if (!(
isMe ||
Object.hasOwn(this.following, note.userId) ||
(note.user.host == null && note.visibility === 'public')
)) {
return;
}
} else {
// 以下の条件に該当するートのみ後続処理に通すので、以下のif文は該当しないートをすべて弾くようにする
// - フォローしているチャンネルの投稿
if (!this.followingChannels.has(note.channelId)) {
return;
}
}
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;

View File

@@ -55,7 +55,7 @@
//#region Script
async function importAppScript() {
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts')
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/boot.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT');

View File

@@ -69,6 +69,9 @@ describe('アンテナ', () => {
let userMutingAlice: User;
let userMutedByAlice: User;
let testChannel: misskey.entities.Channel;
let testMutedChannel: misskey.entities.Channel;
beforeAll(async () => {
root = await signup({ username: 'root' });
alice = await signup({ username: 'alice' });
@@ -120,6 +123,10 @@ describe('アンテナ', () => {
userMutedByAlice = await signup({ username: 'userMutedByAlice' });
await post(userMutedByAlice, { text: 'test' });
await api('mute/create', { userId: userMutedByAlice.id }, alice);
testChannel = (await api('channels/create', { name: 'test' }, root)).body;
testMutedChannel = (await api('channels/create', { name: 'test-muted' }, root)).body;
await api('channels/mute/create', { channelId: testMutedChannel.id }, alice);
}, 1000 * 60 * 10);
beforeEach(async () => {
@@ -605,6 +612,20 @@ describe('アンテナ', () => {
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
],
},
{
label: 'チャンネルノートも含む',
parameters: () => ({ src: 'all' }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}`, channelId: testChannel.id }), included: true },
],
},
{
label: 'ミュートしてるチャンネルは含まない',
parameters: () => ({ src: 'all' }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}`, channelId: testMutedChannel.id }) },
],
},
])('が取得できること($label', async ({ parameters, posts }) => {
const antenna = await successfulApiCall({
endpoint: 'antennas/create',

View File

@@ -506,10 +506,10 @@ describe('クリップ', () => {
});
};
const myFavorites = async (request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => {
const myFavorites = async (parameters: Misskey.entities.ClipsMyFavoritesRequest, request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => {
return successfulApiCall({
endpoint: 'clips/my-favorites',
parameters: {},
parameters,
user: alice,
...request,
});
@@ -562,8 +562,9 @@ describe('クリップ', () => {
await favorite({ clipId: clip.id });
}
// pagenationはない。全部一気にとれる。
const favorited = await myFavorites();
const favorited = await myFavorites({
limit: 30,
});
assert.strictEqual(favorited.length, clips.length);
for (const clip of favorited) {
assert.strictEqual(clip.favoritedCount, 1);
@@ -617,7 +618,7 @@ describe('クリップ', () => {
const clip = await show({ clipId: aliceClip.id });
assert.strictEqual(clip.favoritedCount, 0);
assert.strictEqual(clip.isFavorited, false);
assert.deepStrictEqual(await myFavorites(), []);
assert.deepStrictEqual(await myFavorites({}), []);
});
test.each([
@@ -651,13 +652,13 @@ describe('クリップ', () => {
test('を取得できる。', async () => {
await favorite({ clipId: aliceClip.id });
const favorited = await myFavorites();
const favorited = await myFavorites({});
assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]);
});
test('を取得したとき他人のお気に入りは含まない。', async () => {
await favorite({ clipId: aliceClip.id });
const favorited = await myFavorites({ user: bob });
const favorited = await myFavorites({}, { user: bob });
assert.deepStrictEqual(favorited, []);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
module.exports = async () => {
// DBはUTCっぽいので、テスト側も合わせておく
process.env.TZ = 'UTC';
process.env.NODE_ENV = 'test';
};

View File

@@ -0,0 +1,235 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable */
import { afterEach, beforeEach, describe, expect } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import {
type ChannelFollowingsRepository,
ChannelsRepository,
DriveFilesRepository,
MiChannel,
MiChannelFollowing,
MiDriveFile,
MiUser,
UserProfilesRepository,
UsersRepository,
} from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ChannelFollowingService } from "@/core/ChannelFollowingService.js";
import { MiLocalUser } from "@/models/User.js";
describe('ChannelFollowingService', () => {
let app: TestingModule;
let service: ChannelFollowingService;
let channelsRepository: ChannelsRepository;
let channelFollowingsRepository: ChannelFollowingsRepository;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
let driveFilesRepository: DriveFilesRepository;
let idService: IdService;
let alice: MiLocalUser;
let bob: MiLocalUser;
let channel1: MiChannel;
let channel2: MiChannel;
let channel3: MiChannel;
let driveFile1: MiDriveFile;
let driveFile2: MiDriveFile;
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
username: 'username',
usernameLower: 'username',
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.insert({
userId: user.id,
});
return user;
}
async function createChannel(data: Partial<MiChannel> = {}) {
return await channelsRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => channelsRepository.findOneByOrFail(x.identifiers[0]));
}
async function createChannelFollowing(data: Partial<MiChannelFollowing> = {}) {
return await channelFollowingsRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => channelFollowingsRepository.findOneByOrFail(x.identifiers[0]));
}
async function fetchChannelFollowing() {
return await channelFollowingsRepository.findBy({});
}
async function createDriveFile(data: Partial<MiDriveFile> = {}) {
return await driveFilesRepository
.insert({
id: idService.gen(),
md5: 'md5',
name: 'name',
size: 0,
type: 'type',
storedInternal: false,
url: 'url',
...data,
})
.then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0]));
}
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
CoreModule,
],
providers: [
GlobalEventService,
IdService,
ChannelFollowingService,
],
}).compile();
app.enableShutdownHooks();
service = app.get<ChannelFollowingService>(ChannelFollowingService);
idService = app.get<IdService>(IdService);
channelsRepository = app.get<ChannelsRepository>(DI.channelsRepository);
channelFollowingsRepository = app.get<ChannelFollowingsRepository>(DI.channelFollowingsRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
alice = { ...await createUser({ username: 'alice' }), host: null, uri: null };
bob = { ...await createUser({ username: 'bob' }), host: null, uri: null };
driveFile1 = await createDriveFile();
driveFile2 = await createDriveFile();
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });
channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id });
channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id });
});
afterEach(async () => {
await channelFollowingsRepository.deleteAll();
await channelsRepository.deleteAll();
await userProfilesRepository.deleteAll();
await usersRepository.deleteAll();
});
describe('list', () => {
test('default', async () => {
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
const followings = await service.list({ requestUserId: alice.id });
expect(followings).toHaveLength(2);
expect(followings[0].id).toBe(channel1.id);
expect(followings[0].userId).toBe(alice.id);
expect(followings[0].user).toBeFalsy();
expect(followings[0].bannerId).toBe(driveFile1.id);
expect(followings[0].banner).toBeFalsy();
expect(followings[1].id).toBe(channel2.id);
expect(followings[1].userId).toBe(alice.id);
expect(followings[1].user).toBeFalsy();
expect(followings[1].bannerId).toBe(driveFile2.id);
expect(followings[1].banner).toBeFalsy();
});
test('idOnly', async () => {
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
const followings = await service.list({ requestUserId: alice.id }, { idOnly: true });
expect(followings).toHaveLength(2);
expect(followings[0].id).toBe(channel1.id);
expect(followings[1].id).toBe(channel2.id);
});
test('joinUser', async () => {
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
const followings = await service.list({ requestUserId: alice.id }, { joinUser: true });
expect(followings).toHaveLength(2);
expect(followings[0].id).toBe(channel1.id);
expect(followings[0].user).toEqual(alice);
expect(followings[0].banner).toBeFalsy();
expect(followings[1].id).toBe(channel2.id);
expect(followings[1].user).toEqual(alice);
expect(followings[1].banner).toBeFalsy();
});
test('joinBannerFile', async () => {
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
const followings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true });
expect(followings).toHaveLength(2);
expect(followings[0].id).toBe(channel1.id);
expect(followings[0].user).toBeFalsy();
expect(followings[0].banner).toEqual(driveFile1);
expect(followings[1].id).toBe(channel2.id);
expect(followings[1].user).toBeFalsy();
expect(followings[1].banner).toEqual(driveFile2);
});
});
describe('follow', () => {
test('default', async () => {
await service.follow(alice, channel1);
const followings = await fetchChannelFollowing();
expect(followings).toHaveLength(1);
expect(followings[0].followeeId).toBe(channel1.id);
expect(followings[0].followerId).toBe(alice.id);
});
});
describe('unfollow', () => {
test('default', async () => {
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
await service.unfollow(alice, channel1);
const followings = await fetchChannelFollowing();
expect(followings).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,336 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable */
import { afterEach, beforeEach, describe, expect } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import {
ChannelMutingRepository,
ChannelsRepository,
DriveFilesRepository,
MiChannel,
MiChannelMuting,
MiDriveFile,
MiUser,
UserProfilesRepository,
UsersRepository,
} from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { setTimeout } from 'node:timers/promises';
describe('ChannelMutingService', () => {
let app: TestingModule;
let service: ChannelMutingService;
let channelsRepository: ChannelsRepository;
let channelMutingRepository: ChannelMutingRepository;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
let driveFilesRepository: DriveFilesRepository;
let idService: IdService;
let alice: MiUser;
let bob: MiUser;
let channel1: MiChannel;
let channel2: MiChannel;
let channel3: MiChannel;
let driveFile1: MiDriveFile;
let driveFile2: MiDriveFile;
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
username: 'username',
usernameLower: 'username',
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.insert({
userId: user.id,
});
return user;
}
async function createChannel(data: Partial<MiChannel> = {}) {
return await channelsRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => channelsRepository.findOneByOrFail(x.identifiers[0]));
}
async function createChannelMuting(data: Partial<MiChannelMuting> = {}) {
return await channelMutingRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => channelMutingRepository.findOneByOrFail(x.identifiers[0]));
}
async function fetchChannelMuting() {
return await channelMutingRepository.findBy({});
}
async function createDriveFile(data: Partial<MiDriveFile> = {}) {
return await driveFilesRepository
.insert({
id: idService.gen(),
md5: 'md5',
name: 'name',
size: 0,
type: 'type',
storedInternal: false,
url: 'url',
...data,
})
.then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0]));
}
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
CoreModule,
],
providers: [
GlobalEventService,
IdService,
ChannelMutingService,
],
}).compile();
app.enableShutdownHooks();
service = app.get<ChannelMutingService>(ChannelMutingService);
idService = app.get<IdService>(IdService);
channelsRepository = app.get<ChannelsRepository>(DI.channelsRepository);
channelMutingRepository = app.get<ChannelMutingRepository>(DI.channelMutingRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
alice = await createUser({ username: 'alice' });
bob = await createUser({ username: 'bob' });
driveFile1 = await createDriveFile();
driveFile2 = await createDriveFile();
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });
channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id });
channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id });
});
afterEach(async () => {
await channelMutingRepository.deleteAll();
await channelsRepository.deleteAll();
await userProfilesRepository.deleteAll();
await usersRepository.deleteAll();
});
describe('list', () => {
test('default', async () => {
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
const mutings = await service.list({ requestUserId: alice.id });
expect(mutings).toHaveLength(2);
expect(mutings[0].id).toBe(channel1.id);
expect(mutings[0].userId).toBe(alice.id);
expect(mutings[0].user).toBeFalsy();
expect(mutings[0].bannerId).toBe(driveFile1.id);
expect(mutings[0].banner).toBeFalsy();
expect(mutings[1].id).toBe(channel2.id);
expect(mutings[1].userId).toBe(alice.id);
expect(mutings[1].user).toBeFalsy();
expect(mutings[1].bannerId).toBe(driveFile2.id);
expect(mutings[1].banner).toBeFalsy();
});
test('withoutExpires', async () => {
const now = new Date();
const past = new Date(now);
const future = new Date(now);
past.setMinutes(past.getMinutes() - 1);
future.setMinutes(future.getMinutes() + 1);
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null });
await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future });
const mutings = await service.list({ requestUserId: alice.id });
expect(mutings).toHaveLength(2);
expect(mutings[0].id).toBe(channel2.id);
expect(mutings[1].id).toBe(channel3.id);
});
test('idOnly', async () => {
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true });
expect(mutings).toHaveLength(2);
expect(mutings[0].id).toBe(channel1.id);
expect(mutings[1].id).toBe(channel2.id);
});
test('withoutExpires-idOnly', async () => {
const now = new Date();
const past = new Date(now);
const future = new Date(now);
past.setMinutes(past.getMinutes() - 1);
future.setMinutes(future.getMinutes() + 1);
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null });
await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future });
const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true });
expect(mutings).toHaveLength(2);
expect(mutings[0].id).toBe(channel2.id);
expect(mutings[1].id).toBe(channel3.id);
});
test('joinUser', async () => {
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
const mutings = await service.list({ requestUserId: alice.id }, { joinUser: true });
expect(mutings).toHaveLength(2);
expect(mutings[0].id).toBe(channel1.id);
expect(mutings[0].user).toEqual(alice);
expect(mutings[0].banner).toBeFalsy();
expect(mutings[1].id).toBe(channel2.id);
expect(mutings[1].user).toEqual(alice);
expect(mutings[1].banner).toBeFalsy();
});
test('joinBannerFile', async () => {
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
const mutings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true });
expect(mutings).toHaveLength(2);
expect(mutings[0].id).toBe(channel1.id);
expect(mutings[0].user).toBeFalsy();
expect(mutings[0].banner).toEqual(driveFile1);
expect(mutings[1].id).toBe(channel2.id);
expect(mutings[1].user).toBeFalsy();
expect(mutings[1].banner).toEqual(driveFile2);
});
});
describe('findExpiredMutings', () => {
test('default', async () => {
const now = new Date();
const future = new Date(now);
const past = new Date(now);
future.setMinutes(now.getMinutes() + 1);
past.setMinutes(now.getMinutes() - 1);
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future });
await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past });
const mutings = await service.findExpiredMutings();
expect(mutings).toHaveLength(2);
expect(mutings[0].channelId).toBe(channel1.id);
expect(mutings[1].channelId).toBe(channel3.id);
});
});
describe('isMuted', () => {
test('isMuted: true', async () => {
// キャッシュを読むのでServiceの機能を使って登録し、キャッシュを作成する
await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id });
await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id });
await setTimeout(500);
const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id });
expect(result).toBe(true);
});
test('isMuted: false', async () => {
await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id });
await setTimeout(500);
const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id });
expect(result).toBe(false);
});
});
describe('mute', () => {
test('default', async () => {
await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id });
const muting = await fetchChannelMuting();
expect(muting).toHaveLength(1);
expect(muting[0].channelId).toBe(channel1.id);
});
});
describe('unmute', () => {
test('default', async () => {
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
let muting = await fetchChannelMuting();
expect(muting).toHaveLength(1);
expect(muting[0].channelId).toBe(channel1.id);
await service.unmute({ requestUserId: alice.id, targetChannelId: channel1.id });
muting = await fetchChannelMuting();
expect(muting).toHaveLength(0);
});
});
describe('eraseExpiredMutings', () => {
test('default', async () => {
const now = new Date();
const future = new Date(now);
const past = new Date(now);
future.setMinutes(now.getMinutes() + 1);
past.setMinutes(now.getMinutes() - 1);
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future });
await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past });
await service.eraseExpiredMutings();
const mutings = await fetchChannelMuting();
expect(mutings).toHaveLength(1);
expect(mutings[0].channelId).toBe(channel2.id);
});
});
});

View File

@@ -61,6 +61,7 @@ describe('NoteCreateService', () => {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
renoteChannelId: null,
};
const poll: IPoll = {

View File

@@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import { setTimeout } from 'node:timers/promises';
import { jest } from '@jest/globals';
import { describe, jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers';
@@ -160,16 +160,75 @@ describe('RoleService', () => {
afterEach(async () => {
clock.uninstall();
/**
* Delete meta and roleAssignment first to avoid deadlock due to schema dependencies
* https://github.com/misskey-dev/misskey/issues/16783
*/
await app.get(DI.metasRepository).createQueryBuilder().delete().execute();
await roleAssignmentsRepository.createQueryBuilder().delete().execute();
await Promise.all([
app.get(DI.metasRepository).createQueryBuilder().delete().execute(),
usersRepository.createQueryBuilder().delete().execute(),
rolesRepository.createQueryBuilder().delete().execute(),
roleAssignmentsRepository.createQueryBuilder().delete().execute(),
]);
await app.close();
});
describe('getUserAssigns', () => {
test('アサインされたロールを取得できる', async () => {
const user = await createUser();
const role1 = await createRole({ name: 'a' });
const role2 = await createRole({ name: 'b' });
await roleService.assign(user.id, role1.id);
await roleService.assign(user.id, role2.id);
const assigns = await roleService.getUserAssigns(user.id);
expect(assigns).toHaveLength(2);
expect(assigns.some(a => a.roleId === role1.id)).toBe(true);
expect(assigns.some(a => a.roleId === role2.id)).toBe(true);
});
test('アサインされたロールの有効/期限切れパターンを取得できる', async () => {
const user = await createUser();
const roleNoExpiry = await createRole({ name: 'no-expires' });
const roleNotExpired = await createRole({ name: 'not-expired' });
const roleExpired = await createRole({ name: 'expired' });
// expiresAtなし
await roleService.assign(user.id, roleNoExpiry.id);
// expiresAtあり期限切れでない
const future = new Date(Date.now() + 1000 * 60 * 60); // +1 hour
await roleService.assign(user.id, roleNotExpired.id, future);
// expiresAtあり期限切れ
await assignRole({ userId: user.id, roleId: roleExpired.id, expiresAt: new Date(Date.now() - 1000) });
const assigns = await roleService.getUserAssigns(user.id);
expect(assigns.some(a => a.roleId === roleNoExpiry.id)).toBe(true);
expect(assigns.some(a => a.roleId === roleNotExpired.id)).toBe(true);
expect(assigns.some(a => a.roleId === roleExpired.id)).toBe(false);
});
});
describe('getUserRoles', () => {
test('アサインされたロールとコンディショナルロールの両方が取得できる', async () => {
const user = await createUser();
const manualRole = await createRole({ name: 'manual role' });
const conditionalRole = await createConditionalRole({
id: aidx(),
type: 'isBot',
});
await roleService.assign(user.id, manualRole.id);
await roleService.assign(user.id, conditionalRole.id);
const roles = await roleService.getUserRoles(user.id);
expect(roles.some(r => r.id === manualRole.id)).toBe(true);
expect(roles.some(r => r.id === conditionalRole.id)).toBe(true);
});
});
describe('getUserPolicies', () => {
test('instance default policies', async () => {
const user = await createUser();
@@ -282,6 +341,112 @@ describe('RoleService', () => {
const resultAfter25hAgain = await roleService.getUserPolicies(user.id);
expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true);
});
test('role with no policy set', async () => {
const user = await createUser();
const roleWithPolicy = await createRole({
name: 'roleWithPolicy',
policies: {
pinLimit: {
useDefault: false,
priority: 0,
value: 10,
},
},
});
const roleWithoutPolicy = await createRole({
name: 'roleWithoutPolicy',
policies: {}, // ポリシーが空
});
await roleService.assign(user.id, roleWithPolicy.id);
await roleService.assign(user.id, roleWithoutPolicy.id);
meta.policies = {
pinLimit: 5,
};
const result = await roleService.getUserPolicies(user.id);
// roleWithoutPolicy は default 値 (5) を使い、roleWithPolicy の 10 と比較して大きい方が採用される
expect(result.pinLimit).toBe(10);
});
});
describe('getUserBadgeRoles', () => {
test('手動アサイン済みのバッジロールのみが返る', async () => {
const user = await createUser();
const badgeRole = await createRole({ name: 'badge', asBadge: true });
const normalRole = await createRole({ name: 'normal', asBadge: false });
await roleService.assign(user.id, badgeRole.id);
await roleService.assign(user.id, normalRole.id);
const roles = await roleService.getUserBadgeRoles(user.id);
expect(roles.some(r => r.id === badgeRole.id)).toBe(true);
expect(roles.some(r => r.id === normalRole.id)).toBe(false);
});
test('コンディショナルなバッジロールが条件一致で返る', async () => {
const user = await createUser({ isBot: true });
const condBadgeRole = await createConditionalRole({
id: aidx(),
type: 'isBot',
}, { asBadge: true, name: 'cond-badge' });
const condNonBadgeRole = await createConditionalRole({
id: aidx(),
type: 'isBot',
}, { asBadge: false, name: 'cond-non-badge' });
const roles = await roleService.getUserBadgeRoles(user.id);
expect(roles.some(r => r.id === condBadgeRole.id)).toBe(true);
expect(roles.some(r => r.id === condNonBadgeRole.id)).toBe(false);
});
test('roleAssignedTo 条件のバッジロール: アサイン有無で変化する', async () => {
const [user1, user2] = await Promise.all([createUser(), createUser()]);
const manualRole = await createRole({ name: 'manual' });
const condBadgeRole = await createConditionalRole({
id: aidx(),
type: 'roleAssignedTo',
roleId: manualRole.id,
}, { asBadge: true, name: 'assigned-badge' });
await roleService.assign(user2.id, manualRole.id);
const [roles1, roles2] = await Promise.all([
roleService.getUserBadgeRoles(user1.id),
roleService.getUserBadgeRoles(user2.id),
]);
expect(roles1.some(r => r.id === condBadgeRole.id)).toBe(false);
expect(roles2.some(r => r.id === condBadgeRole.id)).toBe(true);
});
test('期限切れのバッジロールは除外される', async () => {
const user = await createUser();
const roleNoExpiry = await createRole({ name: 'no-exp', asBadge: true });
const roleNotExpired = await createRole({ name: 'not-expired', asBadge: true });
const roleExpired = await createRole({ name: 'expired', asBadge: true });
// expiresAt なし
await roleService.assign(user.id, roleNoExpiry.id);
// expiresAt あり(期限切れでない)
const future = new Date(Date.now() + 1000 * 60 * 60); // +1 hour
await roleService.assign(user.id, roleNotExpired.id, future);
// expiresAt あり(期限切れ)
await assignRole({ userId: user.id, roleId: roleExpired.id, expiresAt: new Date(Date.now() - 1000) });
const rolesBefore = await roleService.getUserBadgeRoles(user.id);
expect(rolesBefore.some(r => r.id === roleNoExpiry.id)).toBe(true);
expect(rolesBefore.some(r => r.id === roleNotExpired.id)).toBe(true);
expect(rolesBefore.some(r => r.id === roleExpired.id)).toBe(false);
// 時間経過で roleNotExpired を失効させる
clock.tick('02:00:00');
const rolesAfter = await roleService.getUserBadgeRoles(user.id);
expect(rolesAfter.some(r => r.id === roleNoExpiry.id)).toBe(true);
expect(rolesAfter.some(r => r.id === roleNotExpired.id)).toBe(false);
});
});
describe('getModeratorIds', () => {
@@ -415,9 +580,9 @@ describe('RoleService', () => {
expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]);
});
test('root has moderator role', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createRoot(),
test('includeAdmins = false, includeRoot = true, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -426,9 +591,11 @@ describe('RoleService', () => {
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: modeUser1.id, roleId: role2.id }),
assignRole({ userId: rootUser.id, roleId: role2.id }),
assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
const result = await roleService.getModeratorIds({
@@ -436,12 +603,12 @@ describe('RoleService', () => {
includeRoot: true,
excludeExpire: false,
});
expect(result).toEqual([modeUser1.id, rootUser.id]);
expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]);
});
test('root has administrator role', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createRoot(),
test('includeAdmins = true, includeRoot = true, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -450,9 +617,11 @@ describe('RoleService', () => {
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: rootUser.id, roleId: role1.id }),
assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: modeUser1.id, roleId: role2.id }),
assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
const result = await roleService.getModeratorIds({
@@ -460,12 +629,12 @@ describe('RoleService', () => {
includeRoot: true,
excludeExpire: false,
});
expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]);
expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id, rootUser.id]);
});
test('root has moderator role(expire)', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createRoot(),
test('includeAdmins = true, includeRoot = true, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -474,17 +643,71 @@ describe('RoleService', () => {
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: modeUser1.id, roleId: role2.id }),
assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
const result = await roleService.getModeratorIds({
includeAdmins: false,
includeAdmins: true,
includeRoot: true,
excludeExpire: true,
});
expect(result).toEqual([rootUser.id]);
expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]);
});
});
describe('getAdministratorIds', () => {
test('should return only user IDs with administrator roles', async () => {
const adminUser1 = await createUser();
const adminUser2 = await createUser();
const normalUser = await createUser();
const moderatorUser = await createUser();
const adminRole = await createRole({ name: 'admin', isAdministrator: true, isModerator: false });
const moderatorRole = await createRole({ name: 'moderator', isModerator: true, isAdministrator: false });
const normalRole = await createRole({ name: 'normal', isAdministrator: false, isModerator: false });
await roleService.assign(adminUser1.id, adminRole.id);
await roleService.assign(adminUser2.id, adminRole.id);
await roleService.assign(moderatorUser.id, moderatorRole.id);
await roleService.assign(normalUser.id, normalRole.id);
const adminIds = await roleService.getAdministratorIds();
// sort for deterministic order
adminIds.sort();
const expectedIds = [adminUser1.id, adminUser2.id].sort();
expect(adminIds).toEqual(expectedIds);
});
test('should return an empty array if no users have administrator roles', async () => {
const normalUser = await createUser();
const normalRole = await createRole({ name: 'normal', isAdministrator: false });
await roleService.assign(normalUser.id, normalRole.id);
const adminIds = await roleService.getAdministratorIds();
expect(adminIds).toHaveLength(0);
});
test('should return an empty array if there are no administrator roles defined', async () => {
await createUser(); // create user to ensure not empty db
const adminIds = await roleService.getAdministratorIds();
expect(adminIds).toHaveLength(0);
});
// TODO: rootユーザーは現在実装に含まれていないため、テストもそれに倣う
test('should not include the root user', async () => {
const rootUser = await createUser();
meta.rootUserId = rootUser.id;
const adminIds = await roleService.getAdministratorIds();
expect(adminIds).not.toContain(rootUser.id);
});
});

View File

@@ -44,6 +44,7 @@ const base: MiNote = {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
renoteChannelId: null,
};
describe('misc:is-renote', () => {

View File

@@ -11,15 +11,15 @@
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "24.9.1",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"rollup": "4.52.4",
"@types/node": "24.9.2",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"rollup": "4.52.5",
"typescript": "5.9.3"
},
"dependencies": {
"estree-walker": "3.0.3",
"magic-string": "0.30.19",
"vite": "7.1.9"
"magic-string": "0.30.21",
"vite": "7.1.11"
}
}

View File

@@ -12,7 +12,7 @@
"dependencies": {
"@discordapp/twemoji": "16.0.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.1",
@@ -26,47 +26,47 @@
"mfm-js": "0.25.0",
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.52.4",
"sass": "1.93.2",
"shiki": "3.13.0",
"rollup": "4.52.5",
"sass": "1.93.3",
"shiki": "3.14.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.9.3",
"uuid": "13.0.0",
"vite": "7.1.9",
"vite": "7.1.11",
"vue": "3.5.22"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.4",
"@misskey-dev/summaly": "5.2.5",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.8",
"@types/micromatch": "4.0.9",
"@types/node": "24.9.1",
"@types/micromatch": "4.0.10",
"@types/node": "24.9.2",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@vitest/coverage-v8": "3.2.4",
"@vue/runtime-core": "3.5.22",
"acorn": "8.15.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.5.0",
"eslint-plugin-vue": "10.5.1",
"fast-glob": "3.3.3",
"happy-dom": "20.0.7",
"happy-dom": "20.0.10",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"msw": "2.11.5",
"msw": "2.11.6",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"start-server-and-test": "2.1.2",
"tsx": "4.20.6",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.1.1",
"vue-component-type-helpers": "3.1.2",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.1"
"vue-tsc": "3.1.2"
}
}

View File

@@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { render } from 'buraha';
const canvas = new OffscreenCanvas(64, 64);
@@ -18,5 +21,5 @@ onmessage = (event) => {
render(event.data.hash, canvas);
const bitmap = canvas.transferToImageBitmap();
postMessage({ id: event.data.id, bitmap }, [bitmap]);
self.postMessage({ id: event.data.id, bitmap }, [bitmap]);
};

View File

@@ -3,12 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/// <reference lib="esnext" />
/// <reference lib="webworker" />
const canvas = globalThis.OffscreenCanvas && new OffscreenCanvas(1, 1);
// 環境によってはOffscreenCanvasが存在しないため
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const gl = canvas?.getContext('webgl2');
if (gl) {
postMessage({ result: true });
self.postMessage({ result: true });
} else {
postMessage({ result: false });
self.postMessage({ result: false });
}

View File

@@ -21,11 +21,11 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.9.1",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"esbuild": "0.25.10",
"eslint-plugin-vue": "10.5.0",
"@types/node": "24.9.2",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"esbuild": "0.25.11",
"eslint-plugin-vue": "10.5.1",
"nodemon": "3.1.10",
"typescript": "5.9.3",
"vue-eslint-parser": "10.2.0"

View File

@@ -33,6 +33,7 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
description: null,
userId: null,
bannerUrl,
bannerId: null,
pinnedNoteIds: [],
color: '#000',
isArchived: false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -14,6 +14,7 @@ export default [
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.{ts,vue}'],
ignores: ['**/*.stories.ts'],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),

View File

@@ -22,9 +22,9 @@
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@sentry/vue": "10.20.0",
"@sentry/vue": "10.22.0",
"@syuilo/aiscript": "1.1.2",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
@@ -33,21 +33,22 @@
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.19",
"astring": "1.9.0",
"broadcast-channel": "7.1.0",
"broadcast-channel": "7.2.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"canvas-confetti": "1.9.4",
"chart.js": "4.5.1",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "3.0.0",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "13.3.0",
"chromatic": "13.3.3",
"compare-versions": "6.1.1",
"cropperjs": "2.0.1",
"cropperjs": "2.1.0",
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"execa": "9.6.0",
"exifreader": "4.32.0",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2",
@@ -55,9 +56,9 @@
"ios-haptics": "0.1.4",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"magic-string": "0.30.19",
"magic-string": "0.30.21",
"matter-js": "0.20.0",
"mediabunny": "1.23.0",
"mediabunny": "1.24.2",
"mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
@@ -66,29 +67,29 @@
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
"rollup": "4.52.4",
"rollup": "4.52.5",
"sanitize-html": "2.17.0",
"sass": "1.93.2",
"shiki": "3.13.0",
"sass": "1.93.3",
"shiki": "3.14.0",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.180.0",
"three": "0.181.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.9.3",
"v-code-diff": "1.13.1",
"vite": "7.1.9",
"vite": "7.1.11",
"vue": "3.5.22",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.4",
"@misskey-dev/summaly": "5.2.5",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "9.1.10",
"@storybook/addon-links": "9.1.16",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/addon-storysource": "8.6.14",
"@storybook/blocks": "8.6.14",
@@ -96,42 +97,42 @@
"@storybook/core-events": "8.6.14",
"@storybook/manager-api": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "9.1.10",
"@storybook/react-vite": "9.1.10",
"@storybook/react": "9.1.16",
"@storybook/react-vite": "9.1.16",
"@storybook/test": "8.6.14",
"@storybook/theming": "8.6.14",
"@storybook/types": "8.6.14",
"@storybook/vue3": "9.1.10",
"@storybook/vue3-vite": "9.1.10",
"@storybook/vue3": "9.1.16",
"@storybook/vue3-vite": "9.1.16",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.8",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.9",
"@types/node": "24.9.1",
"@types/micromatch": "4.0.10",
"@types/node": "24.9.2",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@vitest/coverage-v8": "3.2.4",
"@vue/compiler-core": "3.5.22",
"@vue/runtime-core": "3.5.22",
"acorn": "8.15.0",
"cross-env": "10.1.0",
"cypress": "15.4.0",
"cypress": "15.5.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.5.0",
"eslint-plugin-vue": "10.5.1",
"fast-glob": "3.3.3",
"happy-dom": "20.0.7",
"happy-dom": "20.0.10",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"minimatch": "10.0.3",
"msw": "2.11.5",
"minimatch": "10.1.1",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"nodemon": "3.1.10",
"prettier": "3.6.2",
@@ -139,15 +140,15 @@
"react-dom": "19.2.0",
"seedrandom": "3.0.5",
"start-server-and-test": "2.1.2",
"storybook": "9.1.10",
"storybook": "9.1.16",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.20.6",
"vite-plugin-glsl": "1.5.4",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.2.4",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "3.1.1",
"vue-component-type-helpers": "3.1.2",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.1"
"vue-tsc": "3.1.2"
}
}

View File

@@ -303,13 +303,6 @@ export async function mainBoot() {
});
}
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト
if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
if (store.s.realtimeMode) {
const stream = useStream();

View File

@@ -13,17 +13,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPagination>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="P extends IPaginator">
import * as Misskey from 'misskey-js';
import type { IPaginator } from '@/utility/paginator.js';
import type { IPaginator, ExtractorFunction } from '@/utility/paginator.js';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
paginator: IPaginator;
paginator: P;
noGap?: boolean;
extractor?: (item: any) => Misskey.entities.Channel;
extractor?: ExtractorFunction<P, Misskey.entities.Channel>;
}>(), {
extractor: (item) => item,
});

View File

@@ -27,6 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</I18n>
</div>
<div v-if="$i != null && $i.id === channel.userId" style="color: var(--MI_THEME-warn)">
<i class="ti ti-user-star ti-fw"></i>
<span style="margin-left: 4px;">{{ i18n.ts.youAreAdmin }}</span>
</div>
</div>
</div>
<article v-if="channel.description">
@@ -48,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';

View File

@@ -38,7 +38,7 @@ export const Default = {
};
},
args: {
imageFile: file(),
imageFile: new File([], 'image.webp', { type: 'image/webp' }),
aspectRatio: NaN,
},
parameters: {

View File

@@ -60,7 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
@drop.prevent.stop="onDrop"
@contextmenu.stop="onContextmenu"
>
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
<div :class="$style.tipContainer">
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
</div>
<div :class="$style.folders">
<XFolder
@@ -802,6 +804,10 @@ onBeforeUnmount(() => {
}
}
.tipContainer:not(:empty) {
padding: 16px 32px;
}
.folders,
.files {
display: grid;
@@ -811,6 +817,10 @@ onBeforeUnmount(() => {
}
@container (max-width: 600px) {
.tipContainer:not(:empty) {
padding: 16px;
}
.folders,
.files {
padding: 16px;

View File

@@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div
:class="$style.embedCodeGenPreviewRoot"
>
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
@@ -91,20 +89,18 @@ import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
import { prefer } from '@/preferences.js';
const emit = defineEmits<{
(ev: 'ok'): void;
@@ -314,10 +310,19 @@ onUnmounted(() => {
.embedCodeGenPreviewRoot {
position: relative;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
cursor: not-allowed;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.embedCodeGenPreviewWrapper {

View File

@@ -539,46 +539,44 @@ defineExpose({
}
&.w1 {
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr;
--columns: 5;
}
&.w2 {
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 6;
}
&.w3 {
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 7;
}
&.w4 {
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 8;
}
&.w5 {
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 9;
}
&.h1 {
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
--rows: 4;
}
&.h2 {
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
--rows: 6;
}
&.h3 {
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
--rows: 8;
}
&.h4 {
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
--rows: 10;
}
width: calc((var(--eachSize) * var(--columns)) + (#{$pad} * 2));
height: calc((var(--eachSize) * var(--rows)) + (#{$pad} * 2));
&.asDrawer {
width: 100% !important;
@@ -593,7 +591,7 @@ defineExpose({
> .body {
display: grid;
grid-template-columns: var(--columns);
grid-template-columns: repeat(var(--columns), 1fr);
font-size: 30px;
> .config {
@@ -635,7 +633,7 @@ defineExpose({
::v-deep(section) {
> .body {
display: grid;
grid-template-columns: var(--columns);
grid-template-columns: repeat(var(--columns), 1fr);
font-size: 30px;
> .item {

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkFolder :defaultOpen="true" :canPage="false">
<template #label>{{ fx.name }}</template>
<template #label>{{ fx.uiDefinition.name }}</template>
<template #footer>
<div class="_buttons">
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/>
</MkFolder>
</template>
@@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
import { FXS } from '@/utility/image-effector/fxs.js';
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
const fx = FXS.find((fx) => fx.id === layer.value.fxId);
const fx = FXS[layer.value.fxId];
if (fx == null) {
throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
}
const emit = defineEmits<{
(e: 'del'): void;
(e: 'swapUp'): void;
(e: 'swapDown'): void;
(ev: 'del'): void;
(ev: 'swapUp'): void;
(ev: 'swapDown'): void;
}>();
</script>

View File

@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root">
<div :class="$style.container">
<div :class="$style.preview">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@@ -64,6 +64,7 @@ import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { FXS } from '@/utility/image-effector/fxs.js';
import { genId } from '@/utility/id.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
image: File;
@@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]);
watch(layers, async () => {
if (renderer != null) {
renderer.setLayers(layers);
renderer.render(layers);
}
}, { deep: true });
function addEffect(ev: MouseEvent) {
os.popupMenu(FXS.map((fx) => ({
text: fx.name,
os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({
text: fx.uiDefinition.name,
action: () => {
layers.push({
id: genId(),
fxId: fx.id,
params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
});
fxId: id as keyof typeof FXS,
params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])),
} as ImageEffectorLayer);
},
})), ev.currentTarget ?? ev.target);
}
@@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
const canvasEl = useTemplateRef('canvasEl');
let renderer: ImageEffector<typeof FXS> | null = null;
let renderer: ImageEffector | null = null;
let imageBitmap: ImageBitmap | null = null;
onMounted(async () => {
@@ -146,31 +147,36 @@ onMounted(async () => {
await nextTick(); // waitingがレンダリングされるまで待つ
imageBitmap = await window.createImageBitmap(props.image);
try {
imageBitmap = await window.createImageBitmap(props.image);
const MAX_W = 1000;
const MAX_H = 1000;
let w = imageBitmap.width;
let h = imageBitmap.height;
const MAX_W = 1000;
const MAX_H = 1000;
let w = imageBitmap.width;
let h = imageBitmap.height;
if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h);
w *= scale;
h *= scale;
if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h);
w = Math.floor(w * scale);
h = Math.floor(h * scale);
}
renderer = new ImageEffector({
canvas: canvasEl.value,
renderWidth: w,
renderHeight: h,
image: imageBitmap,
});
await renderer.render(layers);
} catch (err) {
console.error(err);
os.alert({
type: 'error',
text: i18n.ts._imageEffector.failedToLoadImage,
});
}
renderer = new ImageEffector({
canvas: canvasEl.value,
renderWidth: w,
renderHeight: h,
image: imageBitmap,
fxs: FXS,
});
await renderer.setLayers(layers);
renderer.render();
closeWaiting();
});
@@ -196,7 +202,7 @@ async function save() {
await nextTick(); // waitingがレンダリングされるまで待つ
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる
canvasEl.value.toBlob((blob) => {
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
dialog.value?.close();
@@ -208,11 +214,10 @@ const enabled = ref(true);
watch(enabled, () => {
if (renderer != null) {
if (enabled.value) {
renderer.setLayers(layers);
renderer.render(layers);
} else {
renderer.setLayers([]);
renderer.render([]);
}
renderer.render();
}
});
@@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) {
angle: 0,
opacity: 1,
color: [1, 1, 1],
ellipse: false,
},
});
} else if (penMode.value === 'blur') {
@@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) {
scaleY: 0.1,
angle: 0,
radius: 3,
ellipse: false,
},
});
} else if (penMode.value === 'pixelate') {
@@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) {
scaleY: 0.1,
angle: 0,
strength: 0.2,
ellipse: false,
},
});
}
@@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) {
const scaleY = Math.abs(y - startY);
const layerIndex = layers.findIndex((l) => l.id === id);
const layer = layerIndex !== -1 ? layers[layerIndex] : null;
const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null;
if (layer != null) {
layer.params.offsetX = (x + startX) - 1;
layer.params.offsetY = (y + startY) - 1;
@@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) {
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {

View File

@@ -0,0 +1,509 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="true"
@close="cancel()"
@ok="save()"
@closed="emit('closed')"
>
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
</MkRange>
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
</MkInput>
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
</MkInput>
<MkSelect
v-model="params.font" :items="[
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
]"
>
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
</MkSelect>
<MkFolder :defaultOpen="params.labelTop.enabled">
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
<div class="_gaps">
<MkSwitch v-model="params.labelTop.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="params.labelTop.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="params.labelTop.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="params.labelTop.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="params.labelTop.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkFolder :defaultOpen="params.labelBottom.enabled">
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
<div class="_gaps">
<MkSwitch v-model="params.labelBottom.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="params.labelBottom.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="params.labelBottom.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="params.labelBottom.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="params.labelBottom.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkInfo>
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
</MkInfo>
</div>
</div>
</div>
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
import ExifReader from 'exifreader';
import { throttle } from 'throttle-debounce';
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { prefer } from '@/preferences.js';
const $i = ensureSignin();
const props = defineProps<{
presetEditMode?: boolean;
preset?: ImageFramePreset | null;
params?: ImageFrameParams | null;
image?: File | null;
imageCaption?: string | null;
imageFilename?: string | null;
}>();
const preset = deepClone(props.preset) ?? {
id: genId(),
name: '',
};
const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
borderThickness: 0.05,
borderRadius: 0,
labelTop: {
enabled: false,
scale: 1.0,
padding: 0.2,
textBig: '',
textSmall: '',
centered: false,
withQrCode: false,
},
labelBottom: {
enabled: true,
scale: 1.0,
padding: 0.2,
textBig: '{year}/{0month}/{0day}',
textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
centered: false,
withQrCode: true,
},
bgColor: [1, 1, 1],
fgColor: [0, 0, 0],
font: 'sans-serif',
});
const emit = defineEmits<{
(ev: 'ok', frame: ImageFrameParams): void;
(ev: 'presetOk', preset: ImageFramePreset): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
async function cancel() {
if (props.presetEditMode) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm,
});
if (canceled) return;
}
dialog.value?.close();
}
const updateThrottled = throttle(50, () => {
if (renderer != null) {
renderer.render(params);
}
});
watch(params, async (newValue, oldValue) => {
updateThrottled();
}, { deep: true });
const canvasEl = useTemplateRef('canvasEl');
const sampleImage_3_2 = new Image();
sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
const sampleImage_3_2_loading = new Promise<void>(resolve => {
sampleImage_3_2.onload = () => resolve();
});
const sampleImage_2_3 = new Image();
sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
const sampleImage_2_3_loading = new Promise<void>(resolve => {
sampleImage_2_3.onload = () => resolve();
});
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
watch(sampleImageType, async () => {
if (sampleImageType.value === 'provided') return;
if (renderer != null) {
renderer.destroy(false);
renderer = null;
initRenderer();
}
});
let imageFile = props.image;
async function choiceImage() {
const files = await os.chooseFileFromPc({ multiple: false });
if (files.length === 0) return;
imageFile = files[0];
sampleImageType.value = 'provided';
if (renderer != null) {
renderer.destroy(false);
renderer = null;
initRenderer();
}
}
let renderer: ImageFrameRenderer | null = null;
let imageBitmap: ImageBitmap | null = null;
async function initRenderer() {
if (canvasEl.value == null) return;
if (sampleImageType.value === '3_2') {
renderer = new ImageFrameRenderer({
canvas: canvasEl.value,
image: sampleImage_3_2,
exif: null,
caption: 'Example caption',
filename: 'example_file_name.jpg',
renderAsPreview: true,
});
} else if (sampleImageType.value === '2_3') {
renderer = new ImageFrameRenderer({
canvas: canvasEl.value,
image: sampleImage_2_3,
exif: null,
caption: 'Example caption',
filename: 'example_file_name.jpg',
renderAsPreview: true,
});
} else if (imageFile != null) {
imageBitmap = await window.createImageBitmap(imageFile);
const exif = ExifReader.load(await imageFile.arrayBuffer());
renderer = new ImageFrameRenderer({
canvas: canvasEl.value,
image: imageBitmap,
exif: exif,
caption: props.imageCaption ?? null,
filename: props.imageFilename ?? null,
renderAsPreview: true,
});
}
await renderer!.render(params);
}
onMounted(async () => {
const closeWaiting = os.waiting();
await nextTick(); // waitingがレンダリングされるまで待つ
await sampleImage_3_2_loading;
await sampleImage_2_3_loading;
try {
await initRenderer();
} catch (err) {
console.error(err);
os.alert({
type: 'error',
text: i18n.ts._imageFrameEditor.failedToLoadImage,
});
}
closeWaiting();
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
if (imageBitmap != null) {
imageBitmap.close();
imageBitmap = null;
}
});
async function save() {
if (props.presetEditMode) {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
preset.name = name || '';
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('presetOk', {
...preset,
params: deepClone(params),
});
} else {
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('ok', params);
}
}
function getHex(c: [number, number, number]) {
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
}
function getRgb(hex: string | number): [number, number, number] | null {
if (
typeof hex === 'number' ||
typeof hex !== 'string' ||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
) {
return null;
}
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
if (m == null) return [0, 0, 0];
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
}
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.previewTitle {
position: absolute;
z-index: 100;
top: 8px;
left: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 85%;
}
.previewControls {
position: absolute;
z-index: 100;
bottom: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
}
.previewControlsButton {
&.active {
color: var(--MI_THEME-accent);
}
}
.previewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
object-fit: contain;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPagination :paginator="paginator" :direction="direction" :autoLoad="autoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl">
<MkPagination :paginator="paginator" :direction="direction" :autoLoad="autoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl" :forceDisableInfiniteScroll="forceDisableInfiniteScroll">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
@@ -40,26 +40,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup generic="T extends IPaginator<Misskey.entities.Note>">
import * as Misskey from 'misskey-js';
import type { MkPaginationOptions } from '@/components/MkPagination.vue';
import type { IPaginator } from '@/utility/paginator.js';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
const props = withDefaults(defineProps<{
const props = withDefaults(defineProps<MkPaginationOptions & {
paginator: T;
noGap?: boolean;
direction?: 'up' | 'down' | 'both';
autoLoad?: boolean;
pullToRefresh?: boolean;
withControl?: boolean;
}>(), {
autoLoad: true,
direction: 'down',
pullToRefresh: true,
withControl: true,
forceDisableInfiniteScroll: false,
});
useGlobalEvent('noteDeleted', (noteId) => {

View File

@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
:mode="prefer.s.animation ? 'out-in' : undefined"
>
<MkLoading v-if="paginator.fetching.value"/>
@@ -26,14 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else key="_root_" class="_gaps">
<div v-if="direction === 'up' || direction === 'both'" v-show="upButtonVisible">
<MkButton v-if="!upButtonLoading" :class="$style.more" primary rounded @click="upButtonClick">
<MkButton v-if="!upButtonLoading" v-appear="shouldEnableInfiniteScroll ? upButtonClick : null" :class="$style.more" primary rounded @click="upButtonClick">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
<slot :items="getValue(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
<div v-if="direction === 'down' || direction === 'both'" v-show="downButtonVisible">
<MkButton v-if="!downButtonLoading" :class="$style.more" primary rounded @click="downButtonClick">
<MkButton v-if="!downButtonLoading" v-appear="shouldEnableInfiniteScroll ? downButtonClick : null" :class="$style.more" primary rounded @click="downButtonClick">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
@@ -44,6 +44,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
</template>
<script lang="ts">
export type MkPaginationOptions = {
autoLoad?: boolean;
/**
* ページネーションを進める方向
* - up: 上方向
* - down: 下方向 (default)
* - both: 双方向
*
* NOTE: この方向はページネーションの方向であって、アイテムの並び順ではない
*/
direction?: 'up' | 'down' | 'both';
pullToRefresh?: boolean;
withControl?: boolean;
forceDisableInfiniteScroll?: boolean;
};
</script>
<script lang="ts" setup generic="T extends IPaginator">
import { isLink } from '@@/js/is-link.js';
import { onMounted, computed, watch, unref } from 'vue';
@@ -56,24 +74,18 @@ import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import MkPaginationControl from '@/components/MkPaginationControl.vue';
import * as os from '@/os.js';
const props = withDefaults(defineProps<{
const props = withDefaults(defineProps<MkPaginationOptions & {
paginator: T;
// ページネーションを進める方向
// up: 上方向
// down: 下方向 (default)
// both: 双方向
// NOTE: この方向はページネーションの方向であって、アイテムの並び順ではない
direction?: 'up' | 'down' | 'both';
autoLoad?: boolean;
pullToRefresh?: boolean;
withControl?: boolean;
}>(), {
autoLoad: true,
direction: 'down',
pullToRefresh: true,
withControl: false,
forceDisableInfiniteScroll: false,
});
const shouldEnableInfiniteScroll = computed(() => {
return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll;
});
function onContextmenu(ev: MouseEvent) {

View File

@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.header">
<div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
<button v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu">
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu">
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
</button>
</div>
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
<button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button>
<button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
<button ref="submitButtonEl" v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
<div :class="$style.submitInner">
<template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template>
@@ -60,6 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
</div>
</div>
<MkInfo v-if="!store.r.tips.value.postForm" :class="$style.showHowToUse" closable @close="closeTip('postForm')">
<button class="_textButton" @click="showTour">{{ i18n.ts._postForm.showHowToUse }}</button>
</MkInfo>
<MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt">
<I18n :src="i18n.ts.scheduleToPostOnX" tag="span">
<template #x>
@@ -89,7 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
<footer :class="$style.footer">
<footer ref="footerEl" :class="$style.footer">
<div :class="$style.footerLeft">
<button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button>
<button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
@@ -153,6 +156,8 @@ import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
import { useUploader } from '@/composables/use-uploader.js';
import { startTour } from '@/utility/tour.js';
import { closeTip } from '@/tips.js';
const $i = ensureSignin();
@@ -186,6 +191,9 @@ const cwInputEl = useTemplateRef('cwInputEl');
const hashtagsInputEl = useTemplateRef('hashtagsInputEl');
const visibilityButton = useTemplateRef('visibilityButton');
const otherSettingsButton = useTemplateRef('otherSettingsButton');
const accountMenuEl = useTemplateRef('accountMenuEl');
const footerEl = useTemplateRef('footerEl');
const submitButtonEl = useTemplateRef('submitButtonEl');
const posting = ref(false);
const posted = ref(false);
@@ -600,11 +608,30 @@ async function toggleReactionAcceptance() {
//#region その他の設定メニューpopup
function showOtherSettings() {
let reactionAcceptanceIcon = 'ti ti-icons';
let reactionAcceptanceCaption = '';
if (reactionAcceptance.value === 'likeOnly') {
reactionAcceptanceIcon = 'ti ti-heart _love';
} else if (reactionAcceptance.value === 'likeOnlyForRemote') {
reactionAcceptanceIcon = 'ti ti-heart-plus';
switch (reactionAcceptance.value) {
case 'likeOnly':
reactionAcceptanceIcon = 'ti ti-heart _love';
reactionAcceptanceCaption = i18n.ts.likeOnly;
break;
case 'likeOnlyForRemote':
reactionAcceptanceIcon = 'ti ti-heart-plus';
reactionAcceptanceCaption = i18n.ts.likeOnlyForRemote;
break;
case 'nonSensitiveOnly':
reactionAcceptanceCaption = i18n.ts.nonSensitiveOnly;
break;
case 'nonSensitiveOnlyForLocalLikeOnlyForRemote':
reactionAcceptanceCaption = i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote;
break;
default:
reactionAcceptanceCaption = i18n.ts.all;
break;
}
const menuItems = [{
@@ -616,6 +643,7 @@ function showOtherSettings() {
}, { type: 'divider' }, {
icon: reactionAcceptanceIcon,
text: i18n.ts.reactionAcceptance,
caption: reactionAcceptanceCaption,
action: () => {
toggleReactionAcceptance();
},
@@ -684,6 +712,7 @@ function removeVisibleUser(user) {
function clear() {
text.value = '';
cw.value = null;
files.value = [];
poll.value = null;
quoteId.value = null;
@@ -1285,6 +1314,45 @@ function cancelSchedule() {
scheduledAt.value = null;
}
function showTour() {
if (textareaEl.value == null ||
footerEl.value == null ||
accountMenuEl.value == null ||
visibilityButton.value == null ||
otherSettingsButton.value == null ||
submitButtonEl.value == null) {
return;
}
startTour([{
element: textareaEl.value,
title: i18n.ts._postForm._howToUse.content_title,
description: i18n.ts._postForm._howToUse.content_description,
}, {
element: footerEl.value,
title: i18n.ts._postForm._howToUse.toolbar_title,
description: i18n.ts._postForm._howToUse.toolbar_description,
}, {
element: accountMenuEl.value,
title: i18n.ts._postForm._howToUse.account_title,
description: i18n.ts._postForm._howToUse.account_description,
}, {
element: visibilityButton.value,
title: i18n.ts._postForm._howToUse.visibility_title,
description: i18n.ts._postForm._howToUse.visibility_description,
}, {
element: otherSettingsButton.value,
title: i18n.ts._postForm._howToUse.menu_title,
description: i18n.ts._postForm._howToUse.menu_description,
}, {
element: submitButtonEl.value,
title: i18n.ts._postForm._howToUse.submit_title,
description: i18n.ts._postForm._howToUse.submit_description,
}]).then(() => {
closeTip('postForm');
});
}
onMounted(() => {
if (props.autofocus) {
focus();
@@ -1414,9 +1482,11 @@ defineExpose({
}
.avatar {
display: block;
width: 28px;
height: 28px;
margin: auto;
object-fit: cover;
}
.headerRight {
@@ -1575,6 +1645,10 @@ html[data-color-scheme=light] .preview {
margin: 0 20px 16px 20px;
}
.showHowToUse {
margin: 0 20px 16px 20px;
}
.cw,
.hashtags,
.text {

View File

@@ -42,10 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref } from 'vue';
import { instanceName } from '@@/js/config.js';
import { $i } from '@/i.js';
import MkButton from '@/components/MkButton.vue';
import { instance } from '@/instance.js';
import { apiWithDialog, promiseDialog } from '@/os.js';
import { apiWithDialog, promiseDialog, alert } from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { getAccounts } from '@/accounts.js';
@@ -72,11 +73,28 @@ const supported = ref(false);
const pushSubscription = ref<PushSubscription | null>(null);
const pushRegistrationInServer = ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>();
function subscribe() {
async function subscribe() {
if (!registration.value || !supported.value || !instance.swPublickey) return;
if ('Notification' in window) {
let permission = Notification.permission;
if (Notification.permission === 'default') {
permission = await promiseDialog(Notification.requestPermission(), null, null, i18n.ts.pleaseAllowPushNotification);
}
if (permission !== 'granted') {
alert({
type: 'error',
title: i18n.ts.browserPushNotificationDisabled,
text: i18n.tsx.browserPushNotificationDisabledDescription({ serverName: instanceName }),
});
return;
}
}
// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
return promiseDialog(registration.value.pushManager.subscribe({
await promiseDialog(registration.value.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(instance.swPublickey),
})

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<script lang="ts">
import { defineComponent, h, ref, watch } from 'vue';
import { Comment, defineComponent, h, ref, watch } from 'vue';
import MkRadio from './MkRadio.vue';
import type { VNode } from 'vue';
@@ -35,7 +35,7 @@ export default defineComponent({
if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[];
// vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる)
options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if'));
options = options.filter(vnode => vnode.type !== Comment);
return () => h('div', {
class: [

View File

@@ -120,6 +120,7 @@ function onPasskeyLogin(): void {
.then((res) => {
passkeyContext.value = res.context ?? '';
credentialRequest.value = parseRequestOptionsFromJSON({
// @ts-expect-error TODO: misskey-js由来の型@simplewebauthn/typesとフロントエンド由来の型@github/webauthn-jsonが合わない
publicKey: res.option,
});
@@ -134,7 +135,7 @@ function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
waiting.value = true;
if (doingPasskeyFromInputPage.value) {
misskeyApi('signin-with-passkey', {
misskeyApi<Misskey.entities.SigninWithPasskeyResponse>('signin-with-passkey', {
credential: credential.toJSON(),
context: passkeyContext.value,
}).then((res) => {
@@ -149,6 +150,7 @@ function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
tryLogin({
username: userInfo.value.username,
password: password.value,
// @ts-expect-error TODO: misskey-js由来の型@simplewebauthn/typesとフロントエンド由来の型@github/webauthn-jsonが合わない
credential: credential.toJSON(),
});
}
@@ -253,6 +255,7 @@ async function tryLogin(req: Partial<Misskey.entities.SigninFlowRequest>): Promi
case 'passkey': {
if (webAuthnSupported()) {
credentialRequest.value = parseRequestOptionsFromJSON({
// @ts-expect-error TODO: misskey-js由来の型@simplewebauthn/typesとフロントエンド由来の型@github/webauthn-jsonが合わない
publicKey: res.authRequest,
});
page.value = 'passkey';

View File

@@ -0,0 +1,165 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }">
<div :class="[$style.bg]"></div>
<div ref="spotEl" :class="$style.spot"></div>
<div ref="bodyEl" :class="$style.body" class="_panel _shadow">
<div class="_gaps_s">
<div><b>{{ title }}</b></div>
<div>{{ description }}</div>
<div class="_buttons">
<MkButton v-if="hasPrev" small @click="prev"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton v-if="hasNext" small primary @click="next">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton v-else small primary @click="next">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { calcPopupPosition } from '@/utility/popup-position.js';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
title: string;
description: string;
anchorElement?: HTMLElement;
x?: number;
y?: number;
direction?: 'top' | 'bottom' | 'right' | 'left';
hasPrev: boolean;
hasNext: boolean;
}>(), {
direction: 'top',
});
const emit = defineEmits<{
(prev: 'prev'): void;
(next: 'next'): void;
}>();
function prev() {
emit('prev');
}
function next() {
emit('next');
}
const rootEl = useTemplateRef('rootEl');
const bodyEl = useTemplateRef('bodyEl');
const spotEl = useTemplateRef('spotEl');
const zIndex = os.claimZIndex('high');
const spotX = ref(0);
const spotY = ref(0);
const spotWidth = ref(0);
const spotHeight = ref(0);
function setPosition() {
if (spotEl.value == null) return;
if (bodyEl.value == null) return;
if (props.anchorElement == null) return;
const rect = props.anchorElement.getBoundingClientRect();
spotX.value = rect.left;
spotY.value = rect.top;
spotWidth.value = rect.width;
spotHeight.value = rect.height;
const data = calcPopupPosition(bodyEl.value, {
anchorElement: props.anchorElement,
direction: props.direction,
align: 'center',
innerMargin: 16,
x: props.x,
y: props.y,
});
bodyEl.value.style.transformOrigin = data.transformOrigin;
bodyEl.value.style.left = data.left + 'px';
bodyEl.value.style.top = data.top + 'px';
}
let loopHandler;
onMounted(() => {
nextTick(() => {
setPosition();
const loop = () => {
setPosition();
loopHandler = window.requestAnimationFrame(loop);
};
loop();
});
});
onUnmounted(() => {
window.cancelAnimationFrame(loopHandler);
});
</script>
<style lang="scss" module>
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.spot {
--x: v-bind("spotX + 'px'");
--y: v-bind("spotY + 'px'");
--width: v-bind("spotWidth + 'px'");
--height: v-bind("spotHeight + 'px'");
--padding: 8px;
position: absolute;
left: calc(var(--x) - var(--padding));
top: calc(var(--y) - var(--padding));
width: calc(var(--width) + var(--padding) * 2);
height: calc(var(--height) + var(--padding) * 2);
box-sizing: border-box;
border: 1px solid transparent;
border-radius: 8px;
box-shadow: 0 0 0 9999px #000a;
transition: left 0.2s ease-out, top 0.2s ease-out, width 0.2s ease-out, height 0.2s ease-out;
animation: blink 1s infinite;
}
.body {
position: absolute;
padding: 16px 20px;
box-sizing: border-box;
width: max-content;
max-width: min(500px, 100vw);
}
@keyframes blink {
0%, 100% {
background: color(from var(--MI_THEME-accent) srgb r g b / 0.1);
border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75);
}
50% {
background: transparent;
border: 1px solid transparent;
}
}
</style>

View File

@@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_panel" :class="$style.root">
<div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"></div>
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<MkA :to="userPage(user)">
<MkAvatar :class="$style.avatar" :user="user" indicator/>
</MkA>
<div :class="$style.title">
<MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
<p :class="$style.username"><MkAcct :user="user"/></p>
@@ -19,15 +21,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
<div :class="$style.status">
<div :class="$style.statusItem">
<MkA :class="$style.statusItem" :to="userPage(user, 'notes')">
<p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span>
</div>
<div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem">
</MkA>
<MkA v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'following')">
<p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ number(user.followingCount) }}</span>
</div>
<div v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem">
</MkA>
<MkA v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'followers')">
<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span>
</div>
</MkA>
</div>
<MkFollowButton v-if="user.id != $i?.id" :class="$style.follow" :user="user" mini/>
</div>

View File

@@ -15,17 +15,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPagination>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="P extends IPaginator">
import * as Misskey from 'misskey-js';
import type { IPaginator } from '@/utility/paginator.js';
import type { IPaginator, ExtractorFunction } from '@/utility/paginator.js';
import MkUserInfo from '@/components/MkUserInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
paginator: IPaginator;
paginator: P;
noGap?: boolean;
extractor?: (item: any) => Misskey.entities.UserDetailed;
extractor?: ExtractorFunction<P, Misskey.entities.UserDetailed>;
}>(), {
extractor: (item) => item,
});

View File

@@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<path d="M64,32C81.661,32 96,46.339 96,64C95.891,72.184 104,72 104,72C104,72 74.096,80 64,80C52.755,80 24,72 24,72C24,72 31.854,72.018 32,64C32,46.339 46.339,32 64,32Z" style="fill: var(--MI_THEME-popup);"/>
</g>
</svg>
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<MkA :to="userPage(user)">
<MkAvatar :class="$style.avatar" :user="user" indicator/>
</MkA>
<div :class="$style.title">
<MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
<div :class="$style.username"><MkAcct :user="user"/></div>
@@ -32,18 +34,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
</div>
<div :class="$style.status">
<div :class="$style.statusItem">
<MkA :class="$style.statusItem" :to="userPage(user, 'notes')">
<div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
<div>{{ number(user.notesCount) }}</div>
</div>
<div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem">
</MkA>
<MkA v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'following')">
<div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div>
<div>{{ number(user.followingCount) }}</div>
</div>
<div v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem">
</MkA>
<MkA v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'followers')">
<div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div>
<div>{{ number(user.followersCount) }}</div>
</div>
</MkA>
</div>
<button class="_button" :class="$style.menu" @click="showMenu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i && user.id != $i.id" v-model:user="user" :class="$style.follow" mini/>

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<h1 :class="$style.mainTitle">
<!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
<!-- <img class="logo" v-if="instance.logoImageUrl" :src="instance.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
<span>{{ instanceName }}</span>
<MkA to="/">{{ instanceName }}</MkA>
</h1>
<div :class="$style.mainAbout">
<!-- eslint-disable-next-line vue/no-v-html -->

View File

@@ -345,7 +345,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { WatermarkPreset } from '@/utility/watermark.js';
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';

View File

@@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root">
<div :class="$style.container">
<div :class="$style.preview">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<div class="_gaps_s">
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<template #label>
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
@@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<XLayer
v-model:layer="preset.layers[i]"
v-model:layer="layers[i]"
></XLayer>
</MkFolder>
@@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
import type { WatermarkPreset } from '@/utility/watermark.js';
import { WatermarkRenderer } from '@/utility/watermark.js';
import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
@@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { prefer } from '@/preferences.js';
const $i = ensureSignin();
@@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
}
const props = defineProps<{
presetEditMode?: boolean;
preset?: WatermarkPreset | null;
layers?: WatermarkLayers | null;
image?: File | null;
}>();
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
const preset = deepClone(props.preset) ?? {
id: genId(),
name: '',
layers: [],
});
};
const layers = reactive<WatermarkLayers>(props.layers ?? []);
const emit = defineEmits<{
(ev: 'ok', preset: WatermarkPreset): void;
(ev: 'ok', layers: WatermarkLayers): void;
(ev: 'presetOk', preset: WatermarkPreset): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
@@ -180,19 +186,21 @@ const emit = defineEmits<{
const dialog = useTemplateRef('dialog');
async function cancel() {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
});
if (canceled) return;
if (props.presetEditMode) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
});
if (canceled) return;
}
emit('cancel');
dialog.value?.close();
}
watch(preset, async (newValue, oldValue) => {
watch(layers, async (newValue, oldValue) => {
if (renderer != null) {
renderer.setLayers(preset.layers);
renderer.render(layers);
}
}, { deep: true });
@@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
watch(sampleImageType, async () => {
if (sampleImageType.value === 'provided') return;
if (renderer != null) {
renderer.destroy(false);
renderer = null;
@@ -219,6 +228,20 @@ watch(sampleImageType, async () => {
}
});
let imageFile = props.image;
async function choiceImage() {
const files = await os.chooseFileFromPc({ multiple: false });
if (files.length === 0) return;
imageFile = files[0];
sampleImageType.value = 'provided';
if (renderer != null) {
renderer.destroy(false);
renderer = null;
initRenderer();
}
}
let renderer: WatermarkRenderer | null = null;
let imageBitmap: ImageBitmap | null = null;
@@ -239,8 +262,8 @@ async function initRenderer() {
renderHeight: 1500,
image: sampleImage_2_3,
});
} else if (props.image != null) {
imageBitmap = await window.createImageBitmap(props.image);
} else if (imageFile != null) {
imageBitmap = await window.createImageBitmap(imageFile);
const MAX_W = 1000;
const MAX_H = 1000;
@@ -249,8 +272,8 @@ async function initRenderer() {
if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h);
w *= scale;
h *= scale;
w = Math.floor(w * scale);
h = Math.floor(h * scale);
}
renderer = new WatermarkRenderer({
@@ -261,9 +284,7 @@ async function initRenderer() {
});
}
await renderer!.setLayers(preset.layers);
renderer!.render();
await renderer!.render(layers);
}
onMounted(async () => {
@@ -274,7 +295,15 @@ onMounted(async () => {
await sampleImage_3_2_loading;
await sampleImage_2_3_loading;
await initRenderer();
try {
await initRenderer();
} catch (err) {
console.error(err);
os.alert({
type: 'error',
text: i18n.ts._watermarkEditor.failedToLoadImage,
});
}
closeWaiting();
});
@@ -291,77 +320,93 @@ onUnmounted(() => {
});
async function save() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
if (props.presetEditMode) {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
preset.name = name || '';
preset.name = name || '';
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('presetOk', {
...preset,
layers: deepClone(layers),
});
} else {
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('ok', layers);
}
emit('ok', preset);
}
function addLayer(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts._watermarkEditor.text,
action: () => {
preset.layers.push(createTextLayer());
layers.push(createTextLayer());
},
}, {
text: i18n.ts._watermarkEditor.image,
action: () => {
preset.layers.push(createImageLayer());
layers.push(createImageLayer());
},
}, {
text: i18n.ts._watermarkEditor.qr,
action: () => {
preset.layers.push(createQrLayer());
layers.push(createQrLayer());
},
}, {
text: i18n.ts._watermarkEditor.stripe,
action: () => {
preset.layers.push(createStripeLayer());
layers.push(createStripeLayer());
},
}, {
text: i18n.ts._watermarkEditor.polkadot,
action: () => {
preset.layers.push(createPolkadotLayer());
layers.push(createPolkadotLayer());
},
}, {
text: i18n.ts._watermarkEditor.checker,
action: () => {
preset.layers.push(createCheckerLayer());
layers.push(createCheckerLayer());
},
}], ev.currentTarget ?? ev.target);
}
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
const index = preset.layers.findIndex(l => l.id === layer.id);
const index = layers.findIndex(l => l.id === layer.id);
if (index > 0) {
const tmp = preset.layers[index - 1];
preset.layers[index - 1] = preset.layers[index];
preset.layers[index] = tmp;
const tmp = layers[index - 1];
layers[index - 1] = layers[index];
layers[index] = tmp;
}
}
function swapDownLayer(layer: WatermarkPreset['layers'][number]) {
const index = preset.layers.findIndex(l => l.id === layer.id);
if (index < preset.layers.length - 1) {
const tmp = preset.layers[index + 1];
preset.layers[index + 1] = preset.layers[index];
preset.layers[index] = tmp;
const index = layers.findIndex(l => l.id === layer.id);
if (index < layers.length - 1) {
const tmp = layers[index + 1];
layers[index + 1] = layers[index];
layers[index] = tmp;
}
}
function removeLayer(layer: WatermarkPreset['layers'][number]) {
preset.layers = preset.layers.filter(l => l.id !== layer.id);
const index = layers.findIndex(l => l.id === layer.id);
if (index !== -1) {
layers.splice(index, 1);
}
}
</script>
@@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {

View File

@@ -47,9 +47,11 @@ defineProps<{
<style lang="scss" module>
.root {
display: block;
width: 100%;
&.inline {
display: inline-block;
width: auto;
}
}

View File

@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i"/>
</div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft"/>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="[$style.buttons, $style.buttonsLeft]"></div>
<template v-if="pageMetadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@@ -18,9 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<i v-else-if="pageMetadata.icon" :class="[$style.titleIcon, pageMetadata.icon]"></i>
<div :class="$style.title">
<div class="_nowrap" :class="$style.title">
<MkUserName v-if="pageMetadata.userName" :user="pageMetadata.userName" :nowrap="true"/>
<div v-else-if="pageMetadata.title">{{ pageMetadata.title }}</div>
<div v-else-if="pageMetadata.title" class="_nowrap">{{ pageMetadata.title }}</div>
<div v-if="pageMetadata.subtitle" :class="$style.subtitle">
{{ pageMetadata.subtitle }}
</div>
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
</template>
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="[$style.buttons, $style.buttonsRight]">
<template v-for="action in actions">
<button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>
@@ -155,8 +155,10 @@ onUnmounted(() => {
.upper {
--height: 50px;
--margin: var(--MI-margin);
display: flex;
gap: var(--MI-margin);
gap: var(--margin);
align-items: center;
height: var(--height);
.tabs:first-child {
@@ -169,6 +171,7 @@ onUnmounted(() => {
&.thin {
--height: 40px;
--margin: 8px;
> .buttons {
> .button {
@@ -179,12 +182,8 @@ onUnmounted(() => {
&.slim {
text-align: center;
gap: 0;
.tabs:first-child {
margin-left: 0;
}
> .titleContainer {
.titleContainer {
margin: 0 auto;
max-width: 100%;
}
@@ -197,7 +196,7 @@ onUnmounted(() => {
}
.buttons {
--margin: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
min-width: var(--height);
@@ -207,16 +206,6 @@ onUnmounted(() => {
}
}
.buttonsLeft {
composes: buttons;
margin: 0 var(--margin) 0 0;
}
.buttonsRight {
composes: buttons;
margin: 0 0 0 var(--margin);
}
.avatar {
$size: 32px;
display: inline-block;
@@ -231,7 +220,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
height: var(--height);
width: calc(var(--height) - (var(--margin)));
width: calc(var(--height) - 8px);
box-sizing: border-box;
position: relative;
border-radius: 5px;
@@ -254,6 +243,7 @@ onUnmounted(() => {
.titleContainer {
display: flex;
align-items: center;
min-width: 0;
max-width: min(30vw, 400px);
overflow: clip;
white-space: nowrap;
@@ -287,9 +277,6 @@ onUnmounted(() => {
.title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
}

View File

@@ -5,31 +5,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160">
<path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.animLine]"/>
<path d="M80,108L80,72" pathLength="1" :class="[$style.line, $style.animLine]"/>
<path d="M80,52L80,52" :class="[$style.line, $style.animFade]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
<circle cx="80" cy="80" r="56" pathLength="1" :class="[$style.line, $style.animCircle]"/>
</svg>
<svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160">
<path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.animLine]"/>
<path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" pathLength="1" :class="[$style.line, $style.animLine]"/>
<path d="M80,108L80,108" :class="[$style.line, $style.animFade]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
<circle cx="80" cy="80" r="56" pathLength="1" :class="[$style.line, $style.animCircle]"/>
</svg>
<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
<path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.animLine]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
<path d="M62,80L74,92L98,68" pathLength="1" :class="[$style.line, $style.animLine]"/>
<circle cx="80" cy="80" r="56" pathLength="1" :class="[$style.line, $style.animCircle]"/>
</svg>
<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
<path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.animLine]"/>
<path d="M80,64L80,88" pathLength="1" :class="[$style.line, $style.animLine]"/>
<path d="M80,108L80,108" :class="[$style.line, $style.animFade]"/>
<path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:395;" :class="[$style.line, $style.animLine]"/>
<path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" pathLength="1" :class="[$style.line, $style.animLine]"/>
</svg>
<svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160">
<path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.animLine]"/>
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
<path d="M63,63L96,96" pathLength="1" style="--duration:0.3s;" :class="[$style.line, $style.animLine]"/>
<path d="M96,63L63,96" pathLength="1" style="--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
<circle cx="80" cy="80" r="56" pathLength="1" :class="[$style.line, $style.animCircle]"/>
</svg>
<svg v-else-if="type === 'waiting'" :class="[$style.icon, $style.waiting]" viewBox="0 0 160 160">
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleWaiting]"/>
<circle cx="80" cy="80" r="56" pathLength="1" :class="[$style.line, $style.animCircleWaiting]"/>
<circle cx="80" cy="80" r="56" style="opacity: 0.25;" :class="[$style.line]"/>
</svg>
</template>
@@ -80,15 +80,15 @@ const props = defineProps<{
}
.animLine {
stroke-dasharray: var(--l);
stroke-dashoffset: var(--l);
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: line var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
animation-delay: var(--delay, 0s);
}
.animCircle {
stroke-dasharray: var(--l);
stroke-dashoffset: var(--l);
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: line var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
animation-delay: var(--delay, 0s);
transform-origin: center;
@@ -96,8 +96,8 @@ const props = defineProps<{
}
.animCircleWaiting {
stroke-dasharray: var(--l);
stroke-dashoffset: calc(var(--l) / 1.5);
stroke-dasharray: 1;
stroke-dashoffset: calc(1 / 1.5);
animation: waiting 0.75s linear infinite;
transform-origin: center;
}
@@ -110,7 +110,7 @@ const props = defineProps<{
@keyframes line {
0% {
stroke-dashoffset: var(--l);
stroke-dashoffset: 1;
opacity: 0;
}
100% {

View File

@@ -19,10 +19,13 @@ export function useForm<T extends Record<string, any>>(initialState: T, save: (n
const currentState = reactive<T>(copy(initialState));
const previousState = reactive<T>(copy(initialState));
const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
for (const key in currentState) {
modifiedStates[key] = false;
}
const modifiedStates = reactive<Record<keyof T, boolean>>((() => {
const obj: Record<keyof T, boolean> = {} as Record<keyof T, boolean>;
for (const key in initialState) {
obj[key] = false;
}
return obj;
})());
const modified = computed(() => Object.values(modifiedStates).some(v => v));
const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);

View File

@@ -9,6 +9,8 @@ import isAnimated from 'is-file-animated';
import { EventEmitter } from 'eventemitter3';
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
import { genId } from '@/utility/id.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
@@ -16,7 +18,6 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
import { WatermarkRenderer } from '@/utility/watermark.js';
export type UploaderFeatures = {
imageEditing?: boolean;
@@ -28,13 +29,7 @@ const THUMBNAIL_SUPPORTED_TYPES = [
'image/png',
'image/webp',
'image/svg+xml',
];
const IMAGE_COMPRESSION_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/svg+xml',
'image/gif',
];
const IMAGE_EDITING_SUPPORTED_TYPES = [
@@ -49,11 +44,7 @@ const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO
'video/x-matroska',
];
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
const IMAGE_PREPROCESS_NEEDED_TYPES = [
...WATERMARK_SUPPORTED_TYPES,
...IMAGE_COMPRESSION_SUPPORTED_TYPES,
...IMAGE_EDITING_SUPPORTED_TYPES,
];
@@ -83,7 +74,9 @@ export type UploaderItem = {
compressedSize?: number | null;
preprocessedFile?: Blob | null;
file: File;
watermarkPresetId: string | null;
watermarkPreset: WatermarkPreset | null;
watermarkLayers: WatermarkLayers | null;
imageFrameParams: ImageFrameParams | null;
isSensitive?: boolean;
caption?: string | null;
abort?: (() => void) | null;
@@ -135,6 +128,7 @@ export function useUploader(options: {
const id = genId();
const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null;
items.value.push({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
@@ -146,8 +140,10 @@ export function useUploader(options: {
aborted: false,
uploaded: null,
uploadFailed: false,
compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null,
compressionLevel: IMAGE_EDITING_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
watermarkPreset,
watermarkLayers: watermarkPreset?.layers ?? null,
imageFrameParams: null,
file: markRaw(file),
});
const reactiveItem = items.value.at(-1)!;
@@ -253,7 +249,7 @@ export function useUploader(options: {
},
},*/ {
icon: 'ti ti-sparkles',
text: i18n.ts._imageEffector.title + ' (BETA)',
text: i18n.ts._imageEffector.title,
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
image: item.file,
@@ -280,13 +276,14 @@ export function useUploader(options: {
if (
uploaderFeatures.value.watermark &&
$i.policies.watermarkAvailable &&
WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
function changeWatermarkPreset(presetId: string | null) {
item.watermarkPresetId = presetId;
function change(layers: WatermarkLayers | null, preset?: WatermarkPreset | null) {
item.watermarkPreset = preset ?? null;
item.watermarkLayers = layers;
preprocess(item).then(() => {
triggerRef(items);
});
@@ -295,43 +292,109 @@ export function useUploader(options: {
menu.push({
icon: 'ti ti-copyright',
text: i18n.ts.watermark,
caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name),
caption: computed(() => item.watermarkPreset != null ? item.watermarkPreset.name : item.watermarkLayers != null ? i18n.ts.custom : null),
type: 'parent',
children: [{
type: 'radioOption',
text: i18n.ts.none,
active: computed(() => item.watermarkPresetId == null),
action: () => changeWatermarkPreset(null),
}, {
type: 'divider',
}, ...prefer.s.watermarkPresets.map(preset => ({
type: 'radioOption' as const,
text: preset.name,
active: computed(() => item.watermarkPresetId === preset.id),
action: () => changeWatermarkPreset(preset.id),
})), ...(prefer.s.watermarkPresets.length > 0 ? [{
type: 'divider' as const,
}] : []), {
type: 'button',
icon: 'ti ti-plus',
text: i18n.ts.add,
type: 'button' as const,
icon: 'ti ti-pencil',
text: i18n.ts.edit,
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
layers: item.watermarkLayers,
image: item.file,
}, {
ok: (preset) => {
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
changeWatermarkPreset(preset.id);
ok: (layers) => {
change(layers);
},
closed: () => dispose(),
});
},
}],
}, {
type: 'button' as const,
icon: 'ti ti-x',
text: i18n.ts.remove,
action: () => change(null),
}, {
type: 'divider',
}, {
type: 'label',
text: i18n.ts.presets,
}, ...prefer.s.watermarkPresets.map(preset => ({
type: 'radioOption' as const,
text: preset.name,
active: computed(() => item.watermarkPreset?.id === preset.id),
action: () => change(preset.layers, preset),
}))],
});
}
if (
(IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
uploaderFeatures.value.imageEditing &&
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
function change(params: ImageFrameParams | null) {
item.imageFrameParams = params;
preprocess(item).then(() => {
triggerRef(items);
});
}
menu.push({
icon: 'ti ti-device-ipad-horizontal',
text: i18n.ts.frame,
type: 'parent' as const,
children: [{
type: 'button' as const,
icon: 'ti ti-pencil',
text: i18n.ts.edit,
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
params: item.imageFrameParams,
image: item.file,
imageCaption: item.caption ?? null,
imageFilename: item.name,
}, {
ok: (params) => {
change(params);
},
closed: () => dispose(),
});
},
}, ...(item.imageFrameParams != null ? [{
type: 'button' as const,
icon: 'ti ti-x',
text: i18n.ts.remove,
action: () => change(null),
}] : []), {
type: 'divider' as const,
}, {
type: 'label' as const,
text: i18n.ts.presets,
}, ...prefer.s.imageFramePresets.map(preset => ({
type: 'button' as const,
text: preset.name,
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
params: preset.params,
image: item.file,
imageCaption: item.caption ?? null,
imageFilename: item.name,
}, {
ok: (params) => {
change(params);
},
closed: () => dispose(),
});
},
}))],
});
}
if (
(IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
@@ -545,10 +608,10 @@ export function useUploader(options: {
let preprocessedFile: Blob | File = item.file;
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
if (needsWatermark && preset != null) {
const needsWatermark = item.watermarkLayers != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
if (needsWatermark && item.watermarkLayers != null) {
const canvas = window.document.createElement('canvas');
const WatermarkRenderer = await import('@/utility/watermark/WatermarkRenderer.js').then(x => x.WatermarkRenderer);
const renderer = new WatermarkRenderer({
canvas: canvas,
renderWidth: imageBitmap.width,
@@ -556,9 +619,7 @@ export function useUploader(options: {
image: imageBitmap,
});
await renderer.setLayers(preset.layers);
renderer.render();
await renderer.render(item.watermarkLayers);
preprocessedFile = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
@@ -571,8 +632,35 @@ export function useUploader(options: {
});
}
const needsImageFrame = item.imageFrameParams != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type);
if (needsImageFrame && item.imageFrameParams != null) {
const canvas = window.document.createElement('canvas');
const ExifReader = await import('exifreader');
const exif = await ExifReader.load(await item.file.arrayBuffer());
const ImageFrameRenderer = await import('@/utility/image-frame-renderer/ImageFrameRenderer.js').then(x => x.ImageFrameRenderer);
const frameRenderer = new ImageFrameRenderer({
canvas: canvas,
image: await window.createImageBitmap(preprocessedFile),
exif,
caption: item.caption ?? null,
filename: item.name,
});
await frameRenderer.render(item.imageFrameParams);
preprocessedFile = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob == null) {
throw new Error('Failed to convert canvas to blob');
}
resolve(blob);
frameRenderer.destroy();
}, 'image/png');
});
}
const compressionSettings = getCompressionSettings(item.compressionLevel);
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));
if (needsCompress) {
const config = {

View File

@@ -4,13 +4,14 @@
*/
import type { InjectionKey, Ref } from 'vue';
import type { PageMetadata } from '@/page.js';
import type { Router } from '@/router.js';
export const DI = {
routerCurrentDepth: Symbol() as InjectionKey<number>,
router: Symbol() as InjectionKey<Router>,
mock: Symbol() as InjectionKey<boolean>,
pageMetadata: Symbol() as InjectionKey<Ref<Record<string, any> | null>>,
pageMetadata: Symbol() as InjectionKey<Ref<PageMetadata | null>>,
viewId: Symbol() as InjectionKey<string>,
currentStickyTop: Symbol() as InjectionKey<Ref<number>>,
currentStickyBottom: Symbol() as InjectionKey<Ref<number>>,

View File

@@ -7,7 +7,7 @@ import type { Directive } from 'vue';
import { getBgColor } from '@/utility/get-bg-color.js';
import { globalEvents } from '@/events.js';
const handlerMap = new WeakMap<any, any>();
const handlerMap = new WeakMap<HTMLElement, () => void>();
export const adaptiveBorderDirective = {
mounted(src) {

View File

@@ -119,9 +119,9 @@ export const userPreviewDirective = {
// TODO: 新たにプロパティを作るのをやめMapを使う
// ただメモリ的には↓の方が省メモリかもしれないので検討中
const self = (el as any)._userPreviewDirective_ = {} as any;
self.preview = new UserPreview(el, binding.value);
el._userPreviewDirective_ = {
preview: new UserPreview(el, binding.value),
};
},
unmounted(el, binding) {

View File

@@ -0,0 +1,313 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createTexture, initShaderProgram } from '../utility/webgl.js';
export type ImageCompositorFunctionParams = Record<string, any>;
export type ImageCompositorFunction<PS extends ImageCompositorFunctionParams = ImageCompositorFunctionParams> = {
shader: string;
main: (ctx: {
gl: WebGL2RenderingContext;
program: WebGLProgram;
params: PS;
u: Record<string, WebGLUniformLocation>;
width: number;
height: number;
textures: Map<string, { texture: WebGLTexture; width: number; height: number; }>;
}) => void;
};
export type ImageCompositorLayer<FNS extends Record<string, ImageCompositorFunction> = Record<string, ImageCompositorFunction>> = {
[K in keyof FNS]: {
id: string;
functionId: K;
params: Parameters<FNS[K]['main']>[0]['params'];
};
}[keyof FNS];
export function defineImageCompositorFunction<PS extends ImageCompositorFunctionParams>(fn: ImageCompositorFunction<PS>) {
return fn;
}
// TODO: per layer cache
export class ImageCompositor<FNS extends Record<string, ImageCompositorFunction<any>>> {
private gl: WebGL2RenderingContext;
private canvas: HTMLCanvasElement | null = null;
private renderWidth: number;
private renderHeight: number;
private baseTexture: WebGLTexture;
private shaderCache: Map<string, WebGLProgram> = new Map();
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
private nopProgram: WebGLProgram;
private registeredTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
private registeredFunctions: Map<string, ImageCompositorFunction & { id: string; uniforms: string[] }> = new Map();
constructor(options: {
canvas: HTMLCanvasElement;
renderWidth: number;
renderHeight: number;
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
functions: FNS;
}) {
this.canvas = options.canvas;
this.renderWidth = options.renderWidth;
this.renderHeight = options.renderHeight;
this.canvas.width = this.renderWidth;
this.canvas.height = this.renderHeight;
const gl = this.canvas.getContext('webgl2', {
preserveDrawingBuffer: false,
alpha: true,
premultipliedAlpha: false,
});
if (gl == null) throw new Error('Failed to initialize WebGL2 context');
this.gl = gl;
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
if (options.image != null) {
this.baseTexture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.image.width, options.image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, options.image);
gl.bindTexture(gl.TEXTURE_2D, null);
} else {
this.baseTexture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
}
this.nopProgram = initShaderProgram(this.gl, `#version 300 es
in vec2 position;
out vec2 in_uv;
void main() {
in_uv = (position + 1.0) / 2.0;
gl_Position = vec4(position * vec2(1.0, -1.0), 0.0, 1.0);
}
`, `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
out vec4 out_color;
void main() {
out_color = texture(u_texture, in_uv);
}
`);
// レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在なので、とりあえず nopProgram を使って設定する(その後は効果が持続する)
// ref. https://qiita.com/emadurandal/items/5966c8374f03d4de3266
const positionLocation = gl.getAttribLocation(this.nopProgram, 'position');
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(positionLocation);
for (const [id, fn] of Object.entries(options.functions)) {
const uniforms = this.extractUniformNamesFromShader(fn.shader);
this.registeredFunctions.set(id, { ...fn, id, uniforms });
}
}
private extractUniformNamesFromShader(shader: string): string[] {
const uniformRegex = /uniform\s+\w+\s+(\w+)\s*;/g;
const uniforms: string[] = [];
let match;
while ((match = uniformRegex.exec(shader)) !== null) {
uniforms.push(match[1].replace(/^u_/, ''));
}
return uniforms;
}
private renderLayer(layer: ImageCompositorLayer, preTexture: WebGLTexture, invert = false) {
const gl = this.gl;
const fn = this.registeredFunctions.get(layer.functionId);
if (fn == null) return;
const cachedShader = this.shaderCache.get(fn.id);
const shaderProgram = cachedShader ?? initShaderProgram(this.gl, `#version 300 es
in vec2 position;
uniform bool u_invert;
out vec2 in_uv;
void main() {
in_uv = (position + 1.0) / 2.0;
gl_Position = u_invert ? vec4(position * vec2(1.0, -1.0), 0.0, 1.0) : vec4(position, 0.0, 1.0);
}
`, fn.shader);
if (cachedShader == null) {
this.shaderCache.set(fn.id, shaderProgram);
}
gl.useProgram(shaderProgram);
const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
const u_invert = gl.getUniformLocation(shaderProgram, 'u_invert');
gl.uniform1i(u_invert, invert ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
gl.uniform1i(in_texture, 0);
fn.main({
gl: gl,
program: shaderProgram,
params: layer.params,
u: Object.fromEntries(fn.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
width: this.renderWidth,
height: this.renderHeight,
textures: this.registeredTextures,
});
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
public render(layers: (ImageCompositorLayer<FNS>)[]) {
const gl = this.gl;
// 入力をそのまま出力
if (layers.length === 0) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
gl.useProgram(this.nopProgram);
gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
return;
}
let preTexture = this.baseTexture;
for (const layer of layers) {
const isLast = layer === layers.at(-1);
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
const resultTexture = cachedResultTexture ?? createTexture(gl);
if (cachedResultTexture == null) {
this.perLayerResultTextures.set(layer.id, resultTexture);
}
gl.bindTexture(gl.TEXTURE_2D, resultTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.bindTexture(gl.TEXTURE_2D, null);
if (isLast) {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
} else {
const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer();
if (cachedResultFrameBuffer == null) {
this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
}
this.renderLayer(layer as ImageCompositorLayer, preTexture, isLast);
preTexture = resultTexture;
}
}
public registerTexture(key: string, image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
const gl = this.gl;
const existing = this.registeredTextures.get(key);
if (existing != null) {
gl.deleteTexture(existing.texture);
this.registeredTextures.delete(key);
}
const texture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
this.registeredTextures.set(key, {
texture: texture,
width: image.width,
height: image.height,
});
}
public unregisterTexture(key: string) {
const gl = this.gl;
const existing = this.registeredTextures.get(key);
if (existing != null) {
gl.deleteTexture(existing.texture);
this.registeredTextures.delete(key);
}
}
public hasTexture(key: string) {
return this.registeredTextures.has(key);
}
public getKeysOfRegisteredTextures() {
return this.registeredTextures.keys();
}
public changeResolution(width: number, height: number) {
if (this.renderWidth === width && this.renderHeight === height) return;
this.renderWidth = width;
this.renderHeight = height;
if (this.canvas) {
this.canvas.width = this.renderWidth;
this.canvas.height = this.renderHeight;
}
this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
}
/*
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true) {
this.gl.deleteProgram(this.nopProgram);
for (const shader of this.shaderCache.values()) {
this.gl.deleteProgram(shader);
}
this.shaderCache.clear();
for (const texture of this.perLayerResultTextures.values()) {
this.gl.deleteTexture(texture);
}
this.perLayerResultTextures.clear();
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
this.gl.deleteFramebuffer(framebuffer);
}
this.perLayerResultFrameBuffers.clear();
for (const texture of this.registeredTextures.values()) {
this.gl.deleteTexture(texture.texture);
}
this.registeredTextures.clear();
this.gl.deleteTexture(this.baseTexture);
if (disposeCanvas) {
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
if (loseContextExt) loseContextExt.loseContext();
}
}
}

View File

@@ -5,6 +5,8 @@
// PIZZAX --- A lightweight store
// TODO: Misskeyのドメイン知識があるのでutilityなどに移動する
import { onUnmounted, ref, watch } from 'vue';
import { BroadcastChannel } from 'broadcast-channel';
import type { Ref } from 'vue';
@@ -57,7 +59,7 @@ export class Pizzax<T extends StateDef> {
private pizzaxChannel: BroadcastChannel<PizzaxChannelMessage<T>>;
// 簡易的にキューイングして占有ロックとする
private currentIdbJob: Promise<any> = Promise.resolve();
private currentIdbJob: Promise<unknown> = Promise.resolve();
private addIdbSetJob<T>(job: () => Promise<T>) {
const promise = this.currentIdbJob.then(job, err => {
console.error('Pizzax failed to save data to idb!', err);

View File

@@ -257,7 +257,7 @@ const {
const user = ref(result.user);
const info = ref(result.info);
const ips = ref(result.ips);
const ap = ref<any>(null);
const ap = ref<Misskey.entities.ApGetResponse | null>(null);
const moderator = ref(info.value.isModerator);
const silenced = ref(info.value.isSilenced);
const suspended = ref(info.value.isSuspended);

Some files were not shown because too many files have changed in this diff Show More