Merge branch 'develop' into mkjs-n

This commit is contained in:
tamaina
2023-05-10 08:04:38 +00:00
171 changed files with 4574 additions and 1710 deletions

View File

@@ -0,0 +1,13 @@
export class ChannelArchive1683328299359 {
name = 'ChannelArchive1683328299359'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel" ADD "isArchived" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_cc7c72974f1b2f385a8921f094" ON "channel" ("isArchived") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_cc7c72974f1b2f385a8921f094"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "isArchived"`);
}
}

View File

@@ -0,0 +1,11 @@
export class PreventAiLarning1683682889948 {
name = 'PreventAiLarning1683682889948'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "preventAiLarning" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "preventAiLarning"`);
}
}

View File

@@ -0,0 +1,11 @@
export class PublicReactionsDefaultTrue1683683083083 {
name = 'PublicReactionsDefaultTrue1683683083083'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "publicReactions" SET DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "publicReactions" SET DEFAULT false`);
}
}

View File

@@ -34,7 +34,18 @@
"@swc/core-win32-ia32-msvc": "1.3.56",
"@swc/core-win32-x64-msvc": "1.3.56",
"@tensorflow/tfjs": "4.4.0",
"@tensorflow/tfjs-node": "4.4.0"
"@tensorflow/tfjs-node": "4.4.0",
"slacc-android-arm-eabi": "0.0.7",
"slacc-android-arm64": "0.0.7",
"slacc-darwin-arm64": "0.0.7",
"slacc-darwin-universal": "0.0.7",
"slacc-darwin-x64": "0.0.7",
"slacc-linux-arm-gnueabihf": "0.0.7",
"slacc-linux-arm64-gnu": "0.0.7",
"slacc-linux-arm64-musl": "0.0.7",
"slacc-linux-x64-gnu": "0.0.7",
"slacc-win32-arm64-msvc": "0.0.7",
"slacc-win32-x64-msvc": "0.0.7"
},
"dependencies": {
"@aws-sdk/client-s3": "3.321.1",
@@ -91,6 +102,7 @@
"jsdom": "21.1.1",
"json5": "2.2.3",
"jsonld": "8.1.1",
"meilisearch": "0.32.3",
"jsrsasign": "10.8.6",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
@@ -127,6 +139,7 @@
"semver": "7.5.0",
"sharp": "0.32.1",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.7",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",

View File

@@ -2,6 +2,7 @@ import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { MeiliSearch } from 'meilisearch';
import { DI } from './di-symbols.js';
import { loadConfig } from './config.js';
import { createPostgresDataSource } from './postgres.js';
@@ -22,6 +23,21 @@ const $db: Provider = {
inject: [DI.config],
};
const $meilisearch: Provider = {
provide: DI.meilisearch,
useFactory: (config) => {
if (config.meilisearch) {
return new MeiliSearch({
host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`,
apiKey: config.meilisearch.apiKey,
});
} else {
return null;
}
},
inject: [DI.config],
};
const $redis: Provider = {
provide: DI.redis,
useFactory: (config) => {
@@ -73,8 +89,8 @@ const $redisForSub: Provider = {
@Global()
@Module({
imports: [RepositoryModule],
providers: [$config, $db, $redis, $redisForPub, $redisForSub],
exports: [$config, $db, $redis, $redisForPub, $redisForSub, RepositoryModule],
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub],
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
constructor(

View File

@@ -18,10 +18,12 @@ export async function server() {
const serverService = app.get(ServerService);
await serverService.launch();
app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
if (process.env.NODE_ENV !== 'test') {
app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
}
return app;
}
@@ -34,4 +36,6 @@ export async function jobQueue() {
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
return jobQueue;
}

View File

@@ -57,13 +57,11 @@ export type Source = {
db?: number;
prefix?: string;
};
elasticsearch: {
meilisearch?: {
host: string;
port: number;
port: string;
apiKey: string;
ssl?: boolean;
user?: string;
pass?: string;
index?: string;
};
proxy?: string;
@@ -139,6 +137,7 @@ const path = process.env.MISSKEY_CONFIG_YML
: process.env.NODE_ENV === 'test'
? resolve(dir, 'test.yml')
: resolve(dir, 'default.yml');
export function loadConfig() {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');

View File

@@ -50,6 +50,7 @@ import { WebhookService } from './WebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js';
import { FileInfoService } from './FileInfoService.js';
import { SearchService } from './SearchService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js';
@@ -171,6 +172,8 @@ const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', u
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart };
@@ -295,6 +298,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookService,
UtilityService,
FileInfoService,
SearchService,
ChartLoggerService,
FederationChart,
NotesChart,
@@ -413,6 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookService,
$UtilityService,
$FileInfoService,
$SearchService,
$ChartLoggerService,
$FederationChart,
$NotesChart,
@@ -532,6 +537,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookService,
UtilityService,
FileInfoService,
SearchService,
FederationChart,
NotesChart,
UsersChart,
@@ -649,6 +655,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookService,
$UtilityService,
$FileInfoService,
$SearchService,
$FederationChart,
$NotesChart,
$UsersChart,

View File

@@ -449,7 +449,12 @@ export class DriveService {
}: AddFileArgs): Promise<DriveFile> {
let skipNsfwCheck = false;
const instance = await this.metaService.fetch();
if (user == null) skipNsfwCheck = true;
const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
if (user == null) {
skipNsfwCheck = true;
} else if (userRoleNSFW) {
skipNsfwCheck = true;
}
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
@@ -571,6 +576,7 @@ export class DriveService {
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
if (userRoleNSFW) file.isSensitive = true;
if (url !== null) {
file.src = url;

View File

@@ -74,9 +74,7 @@ export class FederatedInstanceService {
.then((response) => {
return response.raw[0];
});
const updated = result.raw[0];
this.federatedInstanceCache.set(updated.host, updated);
this.federatedInstanceCache.set(result.host, result);
}
}

View File

@@ -46,6 +46,7 @@ import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
@@ -198,6 +199,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private apRendererService: ApRendererService,
private roleService: RoleService,
private metaService: MetaService,
private searchService: SearchService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private activeUsersChart: ActiveUsersChart,
@@ -728,17 +730,9 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private index(note: Note) {
if (note.text == null || this.config.elasticsearch == null) return;
/*
es!.index({
index: this.config.elasticsearch.index ?? 'misskey_note',
id: note.id.toString(),
body: {
text: normalizeForSearch(note.text),
userId: note.userId,
userHost: note.userHost,
},
});*/
if (note.text == null && note.cw == null) return;
this.searchService.indexNote(note);
}
@bindThis

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { setTimeout } from 'node:timers/promises';
import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import Bull from 'bull';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -41,9 +42,9 @@ export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue<DbJobData<keyof DbJobMap>>;
export type DbQueue = Bull.Queue;
export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>;
export type ObjectStorageQueue = Bull.Queue;
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
const $system: Provider = {
@@ -118,4 +119,36 @@ const $webhookDeliver: Provider = {
$webhookDeliver,
],
})
export class QueueModule {}
export class QueueModule implements OnApplicationShutdown {
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {}
async onApplicationShutdown(signal: string): Promise<void> {
if (process.env.NODE_ENV === 'test') {
// XXX:
// Shutting down the existing connections causes errors on Jest as
// Misskey has asynchronous postgres/redis connections that are not
// awaited.
// Let's wait for some random time for them to finish.
await setTimeout(5000);
}
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
this.deliverQueue.close(),
this.inboxQueue.close(),
this.dbQueue.close(),
this.relationshipQueue.close(),
this.objectStorageQueue.close(),
this.webhookDeliverQueue.close(),
]);
}
}

View File

@@ -1,15 +1,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import Bull from 'bull';
import type { IActivity } from '@/core/activitypub/type.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature';
import Bull from 'bull';
@Injectable()
export class QueueService {
@@ -152,6 +153,16 @@ export class QueueService {
});
}
@bindThis
public createExportAntennasJob(user: ThinUser) {
return this.dbQueue.add('exportAntennas', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
@bindThis
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importFollowing', {
@@ -235,6 +246,17 @@ export class QueueService {
});
}
@bindThis
public createImportAntennasJob(user: ThinUser, antenna: Antenna) {
return this.dbQueue.add('importAntennas', {
user: { id: user.id },
antenna,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
@bindThis
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return this.dbQueue.add('deleteAccount', {

View File

@@ -25,6 +25,7 @@ export type RolePolicies = {
canSearchNotes: boolean;
canHideAds: boolean;
driveCapacityMb: number;
alwaysMarkNsfw: boolean;
pinLimit: number;
antennaLimit: number;
wordMuteLimit: number;
@@ -45,6 +46,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canSearchNotes: false,
canHideAds: false,
driveCapacityMb: 100,
alwaysMarkNsfw: false,
pinLimit: 5,
antennaLimit: 5,
wordMuteLimit: 200,
@@ -279,6 +281,7 @@ export class RoleService implements OnApplicationShutdown {
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),

View File

@@ -0,0 +1,178 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { Note } from '@/models/entities/Note.js';
import { User } from '@/models/index.js';
import type { NotesRepository } from '@/models/index.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { QueryService } from '@/core/QueryService.js';
import { IdService } from '@/core/IdService.js';
import type { Index, MeiliSearch } from 'meilisearch';
type K = string;
type V = string | number | boolean;
type Q =
{ op: '=', k: K, v: V } |
{ op: '!=', k: K, v: V } |
{ op: '>', k: K, v: number } |
{ op: '<', k: K, v: number } |
{ op: '>=', k: K, v: number } |
{ op: '<=', k: K, v: number } |
{ op: 'and', qs: Q[] } |
{ op: 'or', qs: Q[] } |
{ op: 'not', q: Q };
function compileValue(value: V): string {
if (typeof value === 'string') {
return `'${value}'`; // TODO: escape
} else if (typeof value === 'number') {
return value.toString();
} else if (typeof value === 'boolean') {
return value.toString();
}
throw new Error('unrecognized value');
}
function compileQuery(q: Q): string {
switch (q.op) {
case '=': return `(${q.k} = ${compileValue(q.v)})`;
case '!=': return `(${q.k} != ${compileValue(q.v)})`;
case '>': return `(${q.k} > ${compileValue(q.v)})`;
case '<': return `(${q.k} < ${compileValue(q.v)})`;
case '>=': return `(${q.k} >= ${compileValue(q.v)})`;
case '<=': return `(${q.k} <= ${compileValue(q.v)})`;
case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`;
case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`;
case 'not': return `(NOT ${compileQuery(q.q)})`;
default: throw new Error('unrecognized query operator');
}
}
@Injectable()
export class SearchService {
private meilisearchNoteIndex: Index | null = null;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meilisearch)
private meilisearch: MeiliSearch | null,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private queryService: QueryService,
private idService: IdService,
) {
if (meilisearch) {
this.meilisearchNoteIndex = meilisearch.index('notes');
this.meilisearchNoteIndex.updateSettings({
searchableAttributes: [
'text',
'cw',
],
sortableAttributes: [
'createdAt',
],
filterableAttributes: [
'createdAt',
'userId',
'userHost',
'channelId',
],
typoTolerance: {
enabled: false,
},
pagination: {
maxTotalHits: 10000,
},
});
}
}
@bindThis
public async indexNote(note: Note): Promise<void> {
if (note.text == null && note.cw == null) return;
if (!['home', 'public'].includes(note.visibility)) return;
if (this.meilisearch) {
this.meilisearchNoteIndex!.addDocuments([{
id: note.id,
createdAt: note.createdAt.getTime(),
userId: note.userId,
userHost: note.userHost,
channelId: note.channelId,
cw: note.cw,
text: note.text,
}], {
primaryKey: 'id',
});
}
}
@bindThis
public async searchNote(q: string, me: User | null, opts: {
userId?: Note['userId'] | null;
channelId?: Note['channelId'] | null;
host?: string | null;
}, pagination: {
untilId?: Note['id'];
sinceId?: Note['id'];
limit?: number;
}): Promise<Note[]> {
if (this.meilisearch) {
const filter: Q = {
op: 'and',
qs: [],
};
if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() });
if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() });
if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId });
if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId });
if (opts.host) {
if (opts.host === '.') {
// TODO: Meilisearchが2023/05/07現在値がNULLかどうかのクエリが書けない
} else {
filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
}
}
const res = await this.meilisearchNoteIndex!.search(q, {
sort: ['createdAt:desc'],
matchingStrategy: 'all',
attributesToRetrieve: ['id', 'createdAt'],
filter: compileQuery(filter),
limit: pagination.limit,
});
if (res.hits.length === 0) return [];
const notes = await this.notesRepository.findBy({
id: In(res.hits.map(x => x.id)),
});
return notes.sort((a, b) => a.id > b.id ? -1 : 1);
} else {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
if (opts.userId) {
query.andWhere('note.userId = :userId', { userId: opts.userId });
} else if (opts.channelId) {
query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
}
query
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
return await query.take(pagination.limit).getMany();
}
}
}

View File

@@ -150,7 +150,7 @@ export class ApNoteService {
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility;
const visibleUsers = noteAudience.visibleUsers;

View File

@@ -75,6 +75,7 @@ export class ChannelEntityService {
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
pinnedNoteIds: channel.pinnedNoteIds,
color: channel.color,
isArchived: channel.isArchived,
usersCount: channel.usersCount,
notesCount: channel.notesCount,

View File

@@ -292,7 +292,7 @@ export class UserEntityService implements OnModuleInit {
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
src: User['id'] | User,
me?: { id: User['id'] } | null | undefined,
me?: { id: User['id']; } | null | undefined,
options?: {
detail?: D,
includeSecrets?: boolean,
@@ -326,6 +326,7 @@ export class UserEntityService implements OnModuleInit {
const meId = me ? me.id : null;
const isMe = meId === user.id;
const iAmModerator = me ? await this.roleService.isModerator(me as User) : false;
const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null;
const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin')
@@ -429,6 +430,7 @@ export class UserEntityService implements OnModuleInit {
userId: meId,
targetUserId: user.id,
}).then(row => row?.memo ?? null),
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}),
...(opts.detail && isMe ? {
@@ -443,6 +445,7 @@ export class UserEntityService implements OnModuleInit {
carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed,
noCrawle: profile!.noCrawle,
preventAiLarning: profile!.preventAiLarning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hideOnlineStatus: user.hideOnlineStatus,

View File

@@ -1,6 +1,7 @@
export const DI = {
config: Symbol('config'),
db: Symbol('db'),
meilisearch: Symbol('meilisearch'),
redis: Symbol('redis'),
redisForPub: Symbol('redisForPub'),
redisForSub: Symbol('redisForSub'),

View File

@@ -1,3 +1,4 @@
import { AhoCorasick } from 'slacc';
import RE2 from 're2';
import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js';
@@ -12,6 +13,8 @@ type UserLike = {
id: User['id'];
};
const acCache = new Map<string, AhoCorasick>();
export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array<string | string[]>): Promise<boolean> {
// 自分自身
if (me && (note.userId === me.id)) return false;
@@ -21,7 +24,22 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi
if (text === '') return false;
const matched = mutedWords.some(filter => {
const acable = mutedWords.filter(filter => Array.isArray(filter) && filter.length === 1).map(filter => filter[0]).sort();
const unacable = mutedWords.filter(filter => !Array.isArray(filter) || filter.length !== 1);
const acCacheKey = acable.join('\n');
const ac = acCache.get(acCacheKey) ?? AhoCorasick.withPatterns(acable);
acCache.delete(acCacheKey);
for (const obsoleteKeys of acCache.keys()) {
if (acCache.size > 1000) {
acCache.delete(obsoleteKeys);
}
}
acCache.set(acCacheKey, ac);
if (ac.isMatch(text)) {
return true;
}
const matched = unacable.some(filter => {
if (Array.isArray(filter)) {
return filter.every(keyword => text.includes(keyword));
} else {

View File

@@ -70,6 +70,12 @@ export class Channel {
})
public color: string;
@Index()
@Column('boolean', {
default: false,
})
public isArchived: boolean;
@Index()
@Column('integer', {
default: 0,

View File

@@ -76,7 +76,7 @@ export class UserProfile {
public emailNotificationTypes: string[];
@Column('boolean', {
default: false,
default: true,
})
public publicReactions: boolean;
@@ -147,6 +147,11 @@ export class UserProfile {
})
public noCrawle: boolean;
@Column('boolean', {
default: true,
})
public preventAiLarning: boolean;
@Column('boolean', {
default: false,
})

View File

@@ -30,6 +30,10 @@ export const packedChannelSchema = {
format: 'url',
nullable: true, optional: false,
},
isArchived: {
type: 'boolean',
optional: false, nullable: false,
},
notesCount: {
type: 'number',
nullable: false, optional: false,

View File

@@ -302,7 +302,11 @@ export const packedMeDetailedOnlySchema = {
},
noCrawle: {
type: 'boolean',
nullable: true, optional: false,
nullable: false, optional: false,
},
preventAiLarning: {
type: 'boolean',
nullable: false, optional: false,
},
isExplorable: {
type: 'boolean',

View File

@@ -1,63 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js';
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import type Bull from 'bull';
@Injectable()
export class DbQueueProcessorsService {
constructor(
@Inject(DI.config)
private config: Config,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService,
private exportNotesProcessorService: ExportNotesProcessorService,
private exportFavoritesProcessorService: ExportFavoritesProcessorService,
private exportFollowingProcessorService: ExportFollowingProcessorService,
private exportMutingProcessorService: ExportMutingProcessorService,
private exportBlockingProcessorService: ExportBlockingProcessorService,
private exportUserListsProcessorService: ExportUserListsProcessorService,
private importFollowingProcessorService: ImportFollowingProcessorService,
private importMutingProcessorService: ImportMutingProcessorService,
private importBlockingProcessorService: ImportBlockingProcessorService,
private importUserListsProcessorService: ImportUserListsProcessorService,
private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService,
private deleteAccountProcessorService: DeleteAccountProcessorService,
) {
}
@bindThis
public start(q: Bull.Queue): void {
q.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done));
q.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done));
q.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done));
q.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done));
q.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done));
q.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done));
q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
}
}

View File

@@ -1,25 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js';
import type Bull from 'bull';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ObjectStorageQueueProcessorsService {
constructor(
@Inject(DI.config)
private config: Config,
private deleteFileProcessorService: DeleteFileProcessorService,
private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService,
) {
}
@bindThis
public start(q: Bull.Queue): void {
q.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job));
q.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done));
}
}

View File

@@ -3,14 +3,10 @@ import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js';
import { SystemQueueProcessorsService } from './SystemQueueProcessorsService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
@@ -24,11 +20,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
@@ -55,19 +53,17 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ExportMutingProcessorService,
ExportBlockingProcessorService,
ExportUserListsProcessorService,
ExportAntennasProcessorService,
ImportFollowingProcessorService,
ImportMutingProcessorService,
ImportBlockingProcessorService,
ImportUserListsProcessorService,
ImportCustomEmojisProcessorService,
ImportAntennasProcessorService,
DeleteAccountProcessorService,
DeleteFileProcessorService,
CleanRemoteFilesProcessorService,
RelationshipProcessorService,
SystemQueueProcessorsService,
ObjectStorageQueueProcessorsService,
DbQueueProcessorsService,
RelationshipQueueProcessorsService,
WebhookDeliverProcessorService,
EndedPollNotificationProcessorService,
DeliverProcessorService,

View File

@@ -5,15 +5,36 @@ import type Logger from '@/logger.js';
import { QueueService } from '@/core/QueueService.js';
import { bindThis } from '@/decorators.js';
import { getJobInfo } from './get-job-info.js';
import { SystemQueueProcessorsService } from './SystemQueueProcessorsService.js';
import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js';
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
@Injectable()
export class QueueProcessorService {
@@ -25,14 +46,35 @@ export class QueueProcessorService {
private queueLoggerService: QueueLoggerService,
private queueService: QueueService,
private systemQueueProcessorsService: SystemQueueProcessorsService,
private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService,
private dbQueueProcessorsService: DbQueueProcessorsService,
private relationshipQueueProcessorsService: RelationshipQueueProcessorsService,
private webhookDeliverProcessorService: WebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private deliverProcessorService: DeliverProcessorService,
private inboxProcessorService: InboxProcessorService,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService,
private exportNotesProcessorService: ExportNotesProcessorService,
private exportFavoritesProcessorService: ExportFavoritesProcessorService,
private exportFollowingProcessorService: ExportFollowingProcessorService,
private exportMutingProcessorService: ExportMutingProcessorService,
private exportBlockingProcessorService: ExportBlockingProcessorService,
private exportUserListsProcessorService: ExportUserListsProcessorService,
private exportAntennasProcessorService: ExportAntennasProcessorService,
private importFollowingProcessorService: ImportFollowingProcessorService,
private importMutingProcessorService: ImportMutingProcessorService,
private importBlockingProcessorService: ImportBlockingProcessorService,
private importUserListsProcessorService: ImportUserListsProcessorService,
private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService,
private importAntennasProcessorService: ImportAntennasProcessorService,
private deleteAccountProcessorService: DeleteAccountProcessorService,
private deleteFileProcessorService: DeleteFileProcessorService,
private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService,
private relationshipProcessorService: RelationshipProcessorService,
private tickChartsProcessorService: TickChartsProcessorService,
private resyncChartsProcessorService: ResyncChartsProcessorService,
private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
}
@@ -119,14 +161,6 @@ export class QueueProcessorService {
.on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job));
this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job));
this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
this.dbQueueProcessorsService.start(this.queueService.dbQueue);
this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue);
this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue);
this.queueService.systemQueue.add('tickCharts', {
}, {
repeat: { cron: '55 * * * *' },
@@ -163,6 +197,46 @@ export class QueueProcessorService {
removeOnComplete: true,
});
this.systemQueueProcessorsService.start(this.queueService.systemQueue);
this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job));
this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job));
this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
this.queueService.dbQueue.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done));
this.queueService.dbQueue.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done));
this.queueService.dbQueue.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done));
this.queueService.dbQueue.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done));
this.queueService.dbQueue.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done));
this.queueService.dbQueue.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done));
this.queueService.dbQueue.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
this.queueService.dbQueue.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
this.queueService.dbQueue.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done));
this.queueService.dbQueue.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
this.queueService.dbQueue.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
this.queueService.dbQueue.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
this.queueService.dbQueue.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
this.queueService.dbQueue.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
this.queueService.dbQueue.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
this.queueService.dbQueue.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
this.queueService.dbQueue.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done));
this.queueService.dbQueue.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
this.queueService.objectStorageQueue.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job));
this.queueService.objectStorageQueue.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done));
{
const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
this.queueService.relationshipQueue.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
this.queueService.relationshipQueue.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
this.queueService.relationshipQueue.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
this.queueService.relationshipQueue.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
}
this.queueService.systemQueue.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done));
this.queueService.systemQueue.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done));
this.queueService.systemQueue.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done));
this.queueService.systemQueue.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done));
this.queueService.systemQueue.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done));
this.queueService.systemQueue.process('clean', (job, done) => this.cleanProcessorService.process(job, done));
}
}

View File

@@ -1,26 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
import type Bull from 'bull';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@Injectable()
export class RelationshipQueueProcessorsService {
constructor(
@Inject(DI.config)
private config: Config,
private relationshipProcessorService: RelationshipProcessorService,
) {
}
@bindThis
public start(q: Bull.Queue): void {
const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
}
}

View File

@@ -1,37 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import type Bull from 'bull';
@Injectable()
export class SystemQueueProcessorsService {
constructor(
@Inject(DI.config)
private config: Config,
private tickChartsProcessorService: TickChartsProcessorService,
private resyncChartsProcessorService: ResyncChartsProcessorService,
private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
}
@bindThis
public start(q: Bull.Queue): void {
q.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done));
q.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done));
q.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done));
q.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done));
q.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done));
q.process('clean', (job, done) => this.cleanProcessorService.process(job, done));
}
}

View File

@@ -0,0 +1,103 @@
import fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { format as DateFormat } from 'date-fns';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, User } from '@/models/index.js';
import type { Config } from '@/config.js';
import Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { bindThis } from '@/decorators.js';
import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type { DBExportAntennasData } from '../types.js';
import type Bull from 'bull';
@Injectable()
export class ExportAntennasProcessorService {
private logger: Logger;
constructor (
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.antennasRepository)
private antennsRepository: AntennasRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private driveService: DriveService,
private utilityService: UtilityService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas');
}
@bindThis
public async process(job: Bull.Job<DBExportAntennasData>, done: () => void): Promise<void> {
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
done();
return;
}
const [path, cleanup] = await createTemp();
const stream = fs.createWriteStream(path, { flags: 'a' });
const write = (input: string): Promise<void> => {
return new Promise((resolve, reject) => {
stream.write(input, err => {
if (err) {
this.logger.error(err);
reject();
} else {
resolve();
}
});
});
};
try {
const antennas = await this.antennsRepository.findBy({ userId: job.data.user.id });
write('[');
for (const [index, antenna] of antennas.entries()) {
let users: User[] | undefined;
if (antenna.userListId !== null) {
const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId });
users = await this.usersRepository.findBy({
id: In(joinings.map(j => j.userId)),
});
}
write(JSON.stringify({
name: antenna.name,
src: antenna.src,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
users: antenna.users,
userListAccts: typeof users !== 'undefined' ? users.map((u) => {
return this.utilityService.getFullApAccount(u.username, u.host); // acct
}) : null,
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
}));
if (antennas.length - 1 !== index) {
write(', ');
}
}
write(']');
stream.end();
const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
this.logger.succ('Exported to: ' + driveFile.id);
} finally {
cleanup();
done();
}
}
}

View File

@@ -0,0 +1,96 @@
import { Injectable, Inject } from '@nestjs/common';
import Ajv from 'ajv';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import Logger from '@/logger.js';
import type { AntennasRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import { DBAntennaImportJobData } from '../types.js';
import type Bull from 'bull';
const validate = new Ajv().compile({
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
src: { type: 'string', enum: ['home', 'all', 'users', 'list'] },
userListAccts: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
keywords: { type: 'array', items: {
type: 'array', items: {
type: 'string',
},
} },
excludeKeywords: { type: 'array', items: {
type: 'array', items: {
type: 'string',
},
} },
users: { type: 'array', items: {
type: 'string',
} },
caseSensitive: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
notify: { type: 'boolean' },
},
required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'],
});
@Injectable()
export class ImportAntennasProcessorService {
private logger: Logger;
constructor (
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
private queueLoggerService: QueueLoggerService,
private idService: IdService,
private globalEventService: GlobalEventService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas');
}
@bindThis
public async process(job: Bull.Job<DBAntennaImportJobData>, done: () => void): Promise<void> {
const now = new Date();
try {
for (const antenna of job.data.antenna) {
if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue;
if (!validate(antenna)) {
this.logger.warn('Validation Failed');
continue;
}
const result = await this.antennasRepository.insert({
id: this.idService.genId(),
createdAt: now,
lastUsedAt: now,
userId: job.data.user.id,
name: antenna.name,
src: antenna.src === 'list' && antenna.userListAccts ? 'users' : antenna.src,
userListId: null,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean),
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
this.logger.succ('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result);
}
} catch (err: any) {
this.logger.error(err);
} finally {
done();
}
}
}

View File

@@ -1,3 +1,4 @@
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js';
@@ -33,12 +34,14 @@ export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
export type DbJobMap = {
deleteDriveFiles: DbJobDataWithUser;
exportCustomEmojis: DbJobDataWithUser;
exportAntennas: DBExportAntennasData;
exportNotes: DbJobDataWithUser;
exportFavorites: DbJobDataWithUser;
exportFollowing: DbExportFollowingData;
exportMuting: DbJobDataWithUser;
exportBlocking: DbJobDataWithUser;
exportUserLists: DbJobDataWithUser;
importAntennas: DBAntennaImportJobData;
importFollowing: DbUserImportJobData;
importFollowingToDb: DbUserImportToDbJobData;
importMuting: DbUserImportJobData;
@@ -59,6 +62,10 @@ export type DbExportFollowingData = {
excludeInactive: boolean;
};
export type DBExportAntennasData = {
user: ThinUser
}
export type DbUserDeleteJobData = {
user: ThinUser;
soft?: boolean;
@@ -69,6 +76,11 @@ export type DbUserImportJobData = {
fileId: DriveFile['id'];
};
export type DBAntennaImportJobData = {
user: ThinUser,
antenna: Antenna
}
export type DbUserImportToDbJobData = {
user: ThinUser;
target: string;

View File

@@ -194,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
@@ -202,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
import * as ep___i_notifications from './endpoints/i/notifications.js';
import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
import * as ep___i_pages from './endpoints/i/pages.js';
@@ -530,6 +532,7 @@ const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_
const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default };
const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default };
const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default };
const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default };
const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default };
const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default };
const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default };
@@ -538,6 +541,7 @@ const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass:
const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default };
const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default };
const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default };
const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default };
const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default };
const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default };
@@ -870,6 +874,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_exportNotes,
$i_exportFavorites,
$i_exportUserLists,
$i_exportAntennas,
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
@@ -878,6 +883,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_importFollowing,
$i_importMuting,
$i_importUserLists,
$i_importAntennas,
$i_notifications,
$i_pageLikes,
$i_pages,
@@ -1204,6 +1210,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_exportNotes,
$i_exportFavorites,
$i_exportUserLists,
$i_exportAntennas,
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
@@ -1212,6 +1219,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_importFollowing,
$i_importMuting,
$i_importUserLists,
$i_importAntennas,
$i_notifications,
$i_pageLikes,
$i_pages,

View File

@@ -194,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
@@ -202,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
import * as ep___i_notifications from './endpoints/i/notifications.js';
import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
import * as ep___i_pages from './endpoints/i/pages.js';
@@ -528,6 +530,7 @@ const eps = [
['i/export-notes', ep___i_exportNotes],
['i/export-favorites', ep___i_exportFavorites],
['i/export-user-lists', ep___i_exportUserLists],
['i/export-antennas', ep___i_exportAntennas],
['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts],
@@ -536,6 +539,7 @@ const eps = [
['i/import-following', ep___i_importFollowing],
['i/import-muting', ep___i_importMuting],
['i/import-user-lists', ep___i_importUserLists],
['i/import-antennas', ep___i_importAntennas],
['i/notifications', ep___i_notifications],
['i/page-likes', ep___i_pageLikes],
['i/pages', ep___i_pages],

View File

@@ -68,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
emailVerified: profile.emailVerified,
autoAcceptFollowed: profile.autoAcceptFollowed,
noCrawle: profile.noCrawle,
preventAiLarning: profile.preventAiLarning,
alwaysMarkNsfw: profile.alwaysMarkNsfw,
autoSensitive: profile.autoSensitive,
carefulBot: profile.carefulBot,
@@ -80,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
isSilenced: isSilenced,
isSuspended: user.isSuspended,
lastActiveDate: user.lastActiveDate,
moderationNote: profile.moderationNote,
moderationNote: profile.moderationNote ?? '',
signins,
policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me),

View File

@@ -38,6 +38,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
super(meta, paramDef, async (ps, me) => {
const query = this.channelsRepository.createQueryBuilder('channel')
.where('channel.lastNotedAt IS NOT NULL')
.andWhere('channel.isArchived = FALSE')
.orderBy('channel.lastNotedAt', 'DESC');
const channels = await query.take(10).getMany();

View File

@@ -44,7 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder(), ps.sinceId, ps.untilId)
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId)
.andWhere('channel.isArchived = FALSE')
.andWhere({ userId: me.id });
const channels = await query

View File

@@ -46,15 +46,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId);
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId)
.andWhere('channel.isArchived = FALSE');
if (ps.type === 'nameAndDescription') {
query.andWhere(new Brackets(qb => { qb
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
}));
} else {
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
if (ps.query !== '') {
if (ps.type === 'nameAndDescription') {
query.andWhere(new Brackets(qb => { qb
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
}));
} else {
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
}
}
const channels = await query

View File

@@ -47,6 +47,7 @@ export const paramDef = {
name: { type: 'string', minLength: 1, maxLength: 128 },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
isArchived: { type: 'boolean', nullable: true },
pinnedNoteIds: {
type: 'array',
items: {
@@ -106,6 +107,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
...(ps.description !== undefined ? { description: ps.description } : {}),
...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}),
...(ps.color !== undefined ? { color: ps.color } : {}),
...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}),
...(banner ? { bannerId: banner.id } : {}),
});

View File

@@ -40,8 +40,13 @@ export const meta = {
code: 'NO_SUCH_FOLDER',
id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73',
},
restrictedByRole: {
message: 'This feature is restricted by your role.',
code: 'RESTRICTED_BY_ROLE',
id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7',
},
},
res: {
type: 'object',
optional: false, nullable: false,
@@ -77,7 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
const alwaysMarkNsfw = (await this.roleService.getUserPolicies(me.id)).alwaysMarkNsfw;
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);
}
@@ -93,6 +98,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (ps.comment !== undefined) file.comment = ps.comment;
if (ps.isSensitive !== undefined && ps.isSensitive !== file.isSensitive && alwaysMarkNsfw && !ps.isSensitive) {
throw new ApiError(meta.errors.restrictedByRole);
}
if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive;
if (ps.folderId !== undefined) {

View File

@@ -44,7 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const query = this.usersRepository.createQueryBuilder('user')
.where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) });
.where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) })
.andWhere('user.isSuspended = FALSE');
const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5));

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = {
secure: true,
requireCredential: true,
limit: {
duration: ms('1hour'),
max: 1,
},
} 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 queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
this.queueService.createExportAntennasJob(me);
});
}
}

View File

@@ -0,0 +1,84 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import type { AntennasRepository, DriveFilesRepository, UsersRepository, Antenna as _Antenna } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 1,
},
errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: '3b71d086-c3fa-431c-b01d-ded65a777172',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'e842c379-8ac7-4cf7-b07a-4d4de7e4671c',
},
emptyFile: {
message: 'That file is empty.',
code: 'EMPTY_FILE',
id: '7f60115d-8d93-4b0f-bd0e-3815dcbb389f',
},
tooManyAntennas: {
message: 'You cannot create antenna any more.',
code: 'TOO_MANY_ANTENNAS',
id: '600917d4-a4cb-4cc5-8ba8-7ac8ea3c7779',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor (
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private queueService: QueueService,
private downloadService: DownloadService,
) {
super(meta, paramDef, async (ps, me) => {
const users = await this.usersRepository.findOneBy({ id: me.id });
if (users === null) throw new ApiError(meta.errors.noSuchUser);
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file === null) throw new ApiError(meta.errors.noSuchFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url));
const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id });
if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
throw new ApiError(meta.errors.tooManyAntennas);
}
this.queueService.createImportAntennasJob(me, antennas);
});
}
}
export type Antenna = (_Antenna & { userListAccts: string[] | null })[];

View File

@@ -93,6 +93,12 @@ export const meta = {
code: 'FORBIDDEN_TO_SET_YOURSELF',
id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4',
},
restrictedByRole: {
message: 'This feature is restricted by your role.',
code: 'RESTRICTED_BY_ROLE',
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
},
},
res: {
@@ -132,6 +138,7 @@ export const paramDef = {
carefulBot: { type: 'boolean' },
autoAcceptFollowed: { type: 'boolean' },
noCrawle: { type: 'boolean' },
preventAiLarning: { type: 'boolean' },
isBot: { type: 'boolean' },
isCat: { type: 'boolean' },
showTimelineReplies: { type: 'boolean' },
@@ -236,10 +243,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
if (typeof ps.preventAiLarning === 'boolean') profileUpdates.preventAiLarning = ps.preventAiLarning;
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
if (typeof ps.alwaysMarkNsfw === 'boolean') {
if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
}
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;

View File

@@ -201,10 +201,6 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
elasticsearch: {
type: 'boolean',
optional: false, nullable: false,
},
hcaptcha: {
type: 'boolean',
optional: false, nullable: false,
@@ -331,7 +327,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
response.features = {
registration: !instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup,
elasticsearch: this.config.elasticsearch ? true : false,
hcaptcha: instance.enableHcaptcha,
recaptcha: instance.enableRecaptcha,
turnstile: instance.enableTurnstile,

View File

@@ -262,7 +262,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
let channel: Channel | null = null;
if (ps.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: ps.channelId });
channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);

View File

@@ -1,11 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { SearchService } from '@/core/SearchService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
@@ -43,8 +42,7 @@ export const paramDef = {
offset: { type: 'integer', default: 0 },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
description: 'The local host is represented with `.`.',
},
userId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
@@ -61,11 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.config)
private config: Config,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private searchService: SearchService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -74,27 +69,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.unavailable);
}
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
if (ps.userId) {
query.andWhere('note.userId = :userId', { userId: ps.userId });
} else if (ps.channelId) {
query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
}
query
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
const notes = await query.take(ps.limit).getMany();
const notes = await this.searchService.searchNote(ps.query, me, {
userId: ps.userId,
channelId: ps.channelId,
host: ps.host,
}, {
untilId: ps.untilId,
sinceId: ps.sinceId,
limit: ps.limit,
});
return await this.noteEntityService.packMany(notes, me);
});

View File

@@ -50,8 +50,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.usersRepository.createQueryBuilder('user');
query.where('user.isExplorable = TRUE');
const query = this.usersRepository.createQueryBuilder('user')
.where('user.isExplorable = TRUE')
.andWhere('user.isSuspended = FALSE');
switch (ps.state) {
case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break;

View File

@@ -35,8 +35,8 @@ import { RoleService } from '@/core/RoleService.js';
import manifest from './manifest.json' assert { type: 'json' };
import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js';
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
import { ClientLoggerService } from './ClientLoggerService.js';
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -423,6 +423,10 @@ export class ClientServerService {
: [];
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLarning) {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('user', {
user, profile, me,
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
@@ -467,6 +471,10 @@ export class ClientServerService {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLarning) {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('note', {
note: _note,
profile,
@@ -506,6 +514,10 @@ export class ClientServerService {
} else {
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
}
if (profile.preventAiLarning) {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('page', {
page: _page,
profile,
@@ -530,6 +542,10 @@ export class ClientServerService {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId });
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLarning) {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('flash', {
flash: _flash,
profile,
@@ -554,6 +570,10 @@ export class ClientServerService {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId });
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLarning) {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('clip', {
clip: _clip,
profile,
@@ -576,6 +596,10 @@ export class ClientServerService {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId });
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLarning) {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('gallery-post', {
post: _post,
profile,

View File

@@ -70,10 +70,10 @@ export class UrlPreviewService {
await summaly(url, {
followRedirects: false,
lang: lang ?? 'ja-JP',
agent: {
agent: this.config.proxy ? {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
},
} : undefined,
});
this.logger.succ(`Got preview of ${url}: ${summary.title}`);

View File

@@ -21,6 +21,9 @@ block og
block meta
if profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLarning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)

View File

@@ -21,6 +21,9 @@ block og
block meta
if profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLarning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)

View File

@@ -21,6 +21,9 @@ block og
block meta
if user.host || profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLarning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)

View File

@@ -22,6 +22,9 @@ block og
block meta
if user.host || isRenote || profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLarning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)

View File

@@ -21,6 +21,9 @@ block og
block meta
if profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLarning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)

View File

@@ -20,6 +20,9 @@ block og
block meta
if user.host || profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLarning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)

View File

@@ -403,6 +403,100 @@ describe('Endpoints', () => {
});
});
describe('channels/search', () => {
test('空白検索で一覧を取得できる', async () => {
await api('/channels/create', {
name: 'aaa',
description: 'bbb',
}, bob);
await api('/channels/create', {
name: 'ccc1',
description: 'ddd1',
}, bob);
await api('/channels/create', {
name: 'ccc2',
description: 'ddd2',
}, bob);
const res = await api('/channels/search', {
query: '',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 3);
});
test('名前のみの検索で名前を検索できる', async () => {
const res = await api('/channels/search', {
query: 'aaa',
type: 'nameOnly',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].name, 'aaa');
});
test('名前のみの検索で名前を複数検索できる', async () => {
const res = await api('/channels/search', {
query: 'ccc',
type: 'nameOnly',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
});
test('名前のみの検索で説明は検索できない', async () => {
const res = await api('/channels/search', {
query: 'bbb',
type: 'nameOnly',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 0);
});
test('名前と説明の検索で名前を検索できる', async () => {
const res = await api('/channels/search', {
query: 'ccc1',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].name, 'ccc1');
});
test('名前と説明での検索で説明を検索できる', async () => {
const res = await api('/channels/search', {
query: 'ddd1',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].name, 'ccc1');
});
test('名前と説明の検索で名前を複数検索できる', async () => {
const res = await api('/channels/search', {
query: 'ccc',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
});
test('名前と説明での検索で説明を複数検索できる', async () => {
const res = await api('/channels/search', {
query: 'ddd',
}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
});
});
describe('drive', () => {
test('ドライブ情報を取得できる', async () => {
await uploadFile(alice, {

View File

@@ -10,6 +10,7 @@ import type { INestApplicationContext } from '@nestjs/common';
describe('Account Move', () => {
let app: INestApplicationContext;
let jq: INestApplicationContext;
let url: URL;
let root: any;
@@ -24,7 +25,7 @@ describe('Account Move', () => {
beforeAll(async () => {
app = await startServer();
await jobQueue();
jq = await jobQueue();
const config = loadConfig();
url = new URL(config.url);
const connection = await initTestDb(false);
@@ -39,7 +40,7 @@ describe('Account Move', () => {
}, 1000 * 60 * 2);
afterAll(async () => {
await app.close();
await Promise.all([app.close(), jq.close()]);
});
describe('Create Alias', () => {

View File

@@ -352,6 +352,72 @@ describe('Note', () => {
assert.strictEqual(myNote.renote.reply.files.length, 1);
assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
});
test('NSFWが強制されている場合変更できない', async () => {
const file = await uploadFile(alice);
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
alwaysMarkNsfw: {
useDefault: false,
priority: 0,
value: true,
},
},
}, alice);
assert.strictEqual(res.status, 200);
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
assert.strictEqual(file.body.isSensitive, false);
const nsfwfile = await uploadFile(alice);
assert.strictEqual(nsfwfile.status, 200);
assert.strictEqual(nsfwfile.body.isSensitive, true);
const liftnsfw = await api('drive/files/update', {
fileId: nsfwfile.body.id,
isSensitive: false,
}, alice);
assert.strictEqual(liftnsfw.status, 400);
assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE');
const oldaddnsfw = await api('drive/files/update', {
fileId: file.body.id,
isSensitive: true,
}, alice);
assert.strictEqual(oldaddnsfw.status, 200);
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
});
describe('notes/create', () => {

View File

@@ -51,7 +51,7 @@ describe('ユーザー', () => {
type User = MeDetailed & { token: string };
const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => {
const show = async (id: string, me = root): Promise<MeDetailed | UserDetailedNotMe> => {
return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
};
@@ -145,6 +145,7 @@ describe('ユーザー', () => {
carefulBot: user.carefulBot,
autoAcceptFollowed: user.autoAcceptFollowed,
noCrawle: user.noCrawle,
preventAiLarning: user.preventAiLarning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hideOnlineStatus: user.hideOnlineStatus,
@@ -221,8 +222,8 @@ describe('ユーザー', () => {
}, 1000 * 60 * 2);
beforeAll(async () => {
root = await signup({ username: 'alice' });
alice = root;
root = await signup({ username: 'root' });
alice = await signup({ username: 'alice' });
aliceNote = await post(alice, { text: 'test' }) as any;
alicePage = await page(alice);
aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
@@ -370,7 +371,7 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.pinnedNotes, []);
assert.strictEqual(response.pinnedPageId, null);
assert.strictEqual(response.pinnedPage, null);
assert.strictEqual(response.publicReactions, false);
assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.ffVisibility, 'public');
assert.strictEqual(response.twoFactorEnabled, false);
assert.strictEqual(response.usePasswordLessLogin, false);
@@ -390,6 +391,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.carefulBot, false);
assert.strictEqual(response.autoAcceptFollowed, true);
assert.strictEqual(response.noCrawle, false);
assert.strictEqual(response.preventAiLarning, true);
assert.strictEqual(response.isExplorable, true);
assert.strictEqual(response.isDeleted, false);
assert.strictEqual(response.hideOnlineStatus, false);
@@ -462,6 +464,8 @@ describe('ユーザー', () => {
{ parameters: (): object => ({ autoAcceptFollowed: false }) },
{ parameters: (): object => ({ noCrawle: true }) },
{ parameters: (): object => ({ noCrawle: false }) },
{ parameters: (): object => ({ preventAiLarning: false }) },
{ parameters: (): object => ({ preventAiLarning: true }) },
{ parameters: (): object => ({ isBot: true }) },
{ parameters: (): object => ({ isBot: false }) },
{ parameters: (): object => ({ isCat: true }) },
@@ -566,10 +570,10 @@ describe('ユーザー', () => {
{ label: '空文字', memo: '', expects: null },
{ label: 'null', memo: null },
])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => {
const expected = { ...await show(bob.id), memo: expects === undefined ? memo : expects };
const expected = { ...await show(bob.id, alice), memo: expects === undefined ? memo : expects };
const parameters = { userId: bob.id, memo };
await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice });
const response = await show(bob.id);
const response = await show(bob.id, alice);
assert.deepStrictEqual(response, expected);
});
@@ -588,7 +592,7 @@ describe('ユーザー', () => {
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
// 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する
const users = await Promise.all(response.map(u => show(u.id)));
const users = await Promise.all(response.map(u => show(u.id, alice)));
const expected = users.sort((x, y) => {
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
return index * (parameters.sort?.startsWith('+') ? -1 : 1);
@@ -602,13 +606,13 @@ describe('ユーザー', () => {
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれ', user: (): User => userSuspended },
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => {
const parameters = { limit: 100 };
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected);
});
test.todo('をリスト形式で取得することができる(リモート, hostname指定');
@@ -635,7 +639,7 @@ describe('ユーザー', () => {
{ label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator },
{ label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined },
{ label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced },
{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
//{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
{ label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted },
{ label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
{ label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted },
@@ -717,13 +721,13 @@ describe('ユーザー', () => {
test('を検索することができる', async () => {
const parameters = { query: 'carol', limit: 10 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
const expected = [await show(carol.id)];
const expected = [await show(carol.id, alice)];
assert.deepStrictEqual(response, expected);
});
test('を検索することができる(UserLite)', async () => {
const parameters = { query: 'carol', detail: false, limit: 10 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
const expected = [userLite(await show(carol.id))];
const expected = [userLite(await show(carol.id, alice))];
assert.deepStrictEqual(response, expected);
});
test.each([
@@ -739,7 +743,7 @@ describe('ユーザー', () => {
] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
const parameters = { query: user().username, limit: 1 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('を検索することができる(リモート)');
@@ -760,7 +764,7 @@ describe('ユーザー', () => {
{ label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] },
])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => {
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
const expected = await Promise.all(user().map(u => show(u.id)));
const expected = await Promise.all(user().map(u => show(u.id, alice)));
assert.deepStrictEqual(response, expected);
});
test.each([
@@ -776,7 +780,7 @@ describe('ユーザー', () => {
] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => {
const parameters = { username: user().username };
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('をID&ホスト指定で検索できる(リモート)');
@@ -788,7 +792,7 @@ describe('ユーザー', () => {
const parameters = { userId: alice.id, limit: 5 };
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
user: await show(s.id),
user: await show(s.id, alice),
weight: (usersReplying.length - i) / usersReplying.length,
})));
assert.deepStrictEqual(response, expected);
@@ -800,7 +804,7 @@ describe('ユーザー', () => {
{ label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended },
//{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => {
@@ -808,7 +812,7 @@ describe('ユーザー', () => {
await post(alice, { text: `@${user().username} test`, replyId: replyTo.id });
const parameters = { userId: alice.id, limit: 100 };
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
const expected = (excluded ?? false) ? [] : [await show(user().id)];
const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected);
});
@@ -827,7 +831,7 @@ describe('ユーザー', () => {
await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice });
const parameters = { tag: hashtag, limit: 5, ...sort };
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
const users = await Promise.all(response.map(u => show(u.id)));
const users = await Promise.all(response.map(u => show(u.id, alice)));
const expected = users.sort((x, y) => {
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
return index * (parameters.sort.startsWith('+') ? -1 : 1);
@@ -841,10 +845,10 @@ describe('ユーザー', () => {
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
{ label: 'サスペンドユーザーが含まれ', user: (): User => userSuspended },
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => {
] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user, excluded }) => {
const hashtag = `user_test${user().username}`;
if (user() !== userSuspended) {
// サスペンドユーザーはupdateできない。
@@ -852,7 +856,7 @@ describe('ユーザー', () => {
}
const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const;
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
const expected = [await show(user().id)];
const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('をハッシュタグ指定で取得することができる(リモート)');
@@ -875,7 +879,7 @@ describe('ユーザー', () => {
await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root });
const parameters = {} as const;
const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice });
const expected = await Promise.all([bob, carol].map(u => show(u.id)));
const expected = await Promise.all([bob, carol].map(u => show(u.id, alice)));
assert.deepStrictEqual(response, expected);
});

View File

@@ -0,0 +1,49 @@
import { checkWordMute } from '@/misc/check-word-mute.js';
describe(checkWordMute, () => {
describe('Slacc boost mode', () => {
it('should return false if mutedWords is empty', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [])).toBe(false);
});
it('should return true if mutedWords is not empty and text contains muted word', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo']])).toBe(true);
});
it('should return false if mutedWords is not empty and text does not contain muted word', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['bar']])).toBe(false);
});
it('should return false when the note is written by me even if mutedWords is not empty and text contains muted word', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo' }, { id: '1' }, [['foo']])).toBe(false);
});
it('should return true if mutedWords is not empty and text contains muted word in CW', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['bar']])).toBe(true);
});
it('should return true if mutedWords is not empty and text contains muted word in both CW and text', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['bar']])).toBe(true);
});
it('should return true if mutedWords is not empty and text does not contain muted word in both CW and text', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['baz']])).toBe(true);
});
});
describe('normal mode', () => {
it('should return false if text does not contain muted words', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo', 'bar']])).toBe(false);
});
it('should return true if text contains muted words', async () => {
expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, [['foo', 'bar']])).toBe(true);
});
it('should return false when the note is written by me even if text contains muted words', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, [['foo', 'bar']])).toBe(false);
});
});
describe('RegExp mode', () => {
it('should return false if text does not contain muted words', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo' }, null, ['/bar/'])).toBe(false);
});
it('should return true if text contains muted words', async () => {
expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, ['/bar/'])).toBe(true);
});
it('should return false when the note is written by me even if text contains muted words', async () => {
expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, ['/bar/'])).toBe(false);
});
});
});

View File

@@ -399,6 +399,8 @@ Promise.all([
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/pages/user/home.vue'),
])
.then((globs) => globs.flat())

View File

@@ -3,6 +3,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { userDetailed } from './fakes';
import locale from './locale';
import { commonHandlers, onUnhandledRequest } from './mocks';
import themes from './themes';
@@ -10,6 +11,7 @@ import '../src/style.scss';
const appInitialized = Symbol();
let lastStory = null;
let moduleInitialized = false;
let unobserve = () => {};
let misskeyOS = null;
@@ -42,10 +44,19 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
unobserve = () => observer.disconnect();
}
function initLocalStorage() {
localStorage.clear();
localStorage.setItem('account', JSON.stringify({
...userDetailed(),
policies: {},
}));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({
onUnhandledRequest,
});
localStorage.setItem("locale", JSON.stringify(locale));
initLocalStorage();
queueMicrotask(() => {
Promise.all([
import('../src/components'),
@@ -76,6 +87,27 @@ queueMicrotask(() => {
const preview = {
decorators: [
(Story, context) => {
if (lastStory === context.id) {
lastStory = null;
} else {
lastStory = context.id;
const channel = addons.getChannel();
const resetIndexedDBPromise = globalThis.indexedDB?.databases
? indexedDB.databases().then((r) => {
for (var i = 0; i < r.length; i++) {
indexedDB.deleteDatabase(r[i].name!);
}
}).catch(() => {})
: Promise.resolve();
const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => {
// @ts-expect-error
defaultStore.init();
}).catch(() => {});
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
initLocalStorage();
channel.emit(FORCE_REMOUNT, { storyId: context.id });
});
}
const story = Story();
if (!moduleInitialized) {
const channel = addons.getChannel();

View File

@@ -19,7 +19,7 @@
"@rollup/plugin-json": "6.0.0",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/pluginutils": "5.0.2",
"@syuilo/aiscript": "0.13.1",
"@syuilo/aiscript": "0.13.2",
"@tabler/icons-webfont": "2.17.0",
"@vitejs/plugin-vue": "4.2.1",
"@vue-macros/reactivity-transform": "^0.3.5",

View File

@@ -32,8 +32,8 @@
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
@@ -183,7 +183,7 @@ onBeforeUnmount(() => {
box-sizing: border-box;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
border-radius: 16px;
}
.icon {

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
<div :class="[$style.input, { inline, disabled, focused }]">
<div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]">
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<input
ref="inputEl"

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="hide" :class="$style.hidden" @click="hide = false">
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode" />
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
@@ -20,8 +20,10 @@
<div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
</div>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
</div>
</template>
@@ -33,6 +35,8 @@ import bytes from '@/filters/bytes';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { iAmModerator } from '@/account';
const props = defineProps<{
image: misskey.entities.DriveFile;
@@ -46,7 +50,7 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
? props.image.url
: defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.image.url)
: props.image.thumbnailUrl
: props.image.thumbnailUrl,
);
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
@@ -56,6 +60,17 @@ watch(() => props.image, () => {
deep: true,
immediate: true,
});
function showMenu(ev: MouseEvent) {
os.popupMenu([...(iAmModerator ? [{
text: i18n.ts.markAsSensitive,
icon: 'ti ti-eye-off',
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
}] : [])], ev.currentTarget ?? ev.target);
}
</script>
<style lang="scss" module>
@@ -105,6 +120,21 @@ watch(() => props.image, () => {
right: 12px;
}
.menu {
display: block;
position: absolute;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
color: #fff;
font-size: 0.8em;
padding: 6px 8px;
text-align: center;
bottom: 12px;
right: 12px;
}
.imageContainer {
display: block;
cursor: zoom-in;
@@ -135,6 +165,7 @@ watch(() => props.image, () => {
color: var(--accentLighten);
display: inline-block;
font-weight: bold;
padding: 0 6px;
font-size: 12px;
padding: 2px 6px;
}
</style>

View File

@@ -7,7 +7,6 @@
:class="[
$style.medias,
count <= 4 ? $style['n' + count] : $style.nMany,
$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
@@ -44,37 +43,6 @@ const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
function calcAspectRatio() {
if (!gallery.value) return;
let img = props.mediaList[0];
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
gallery.value.style.aspectRatio = '';
return;
}
// アスペクト比上限設定では、横長の場合は高さを縮小させる
const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
switch (defaultStore.state.mediaListWithOneImageAppearance) {
case '16_9':
gallery.value.style.aspectRatio = ratioMax(16 / 9);
break;
case '1_1':
gallery.value.style.aspectRatio = ratioMax(1);
break;
case '2_3':
gallery.value.style.aspectRatio = ratioMax(2 / 3);
break;
default:
gallery.value.style.aspectRatio = '';
break;
}
}
watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio());
onMounted(() => {
const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
@@ -194,36 +162,12 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
display: grid;
grid-gap: 8px;
// for webkit
height: 100%;
&.n1 {
aspect-ratio: 16/9;
grid-template-rows: 1fr;
// default (expand)
min-height: 64px;
max-height: clamp(
64px,
calc(var(--containerHeight, 100svh) * 0.5), // but --containerHeight can broken (too big)
min(334px, 50vh)
);
&.n116_9 {
min-height: none;
max-height: none;
aspect-ratio: 16 / 9; // fallback
}
&.n11_1{
min-height: none;
max-height: none;
aspect-ratio: 1 / 1; // fallback
}
&.n12_3 {
min-height: none;
max-height: none;
aspect-ratio: 2 / 3; // fallback
}
}
&.n2 {

View File

@@ -1,15 +1,15 @@
<template>
<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
<div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: height ? `${height}px` : null }" @keydown="onKeydown">
<div ref="headerEl" class="header">
<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<span class="title">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
<div ref="headerEl" :class="$style.header">
<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title">
<slot name="header"></slot>
</span>
<button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
<button v-if="!withOkButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
</div>
<div class="body">
<div :class="$style.body">
<slot :width="bodyWidth" :height="bodyHeight"></slot>
</div>
</div>
@@ -24,12 +24,12 @@ const props = withDefaults(defineProps<{
withOkButton: boolean;
okButtonDisabled: boolean;
width: number;
height: number | null;
height: number;
}>(), {
withOkButton: false,
okButtonDisabled: false,
width: 400,
height: null,
height: 500,
});
const emit = defineEmits<{
@@ -81,15 +81,13 @@ defineExpose({
});
</script>
<style lang="scss" scoped>
.ebkgoccj {
<style lang="scss" module>
.root {
margin: auto;
max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
contain: content;
container-type: inline-size;
border-radius: var(--radius);
--root-margin: 24px;
@@ -98,50 +96,52 @@ defineExpose({
--root-margin: 16px;
}
> .header {
$height: 46px;
$height-narrow: 42px;
display: flex;
flex-shrink: 0;
background: var(--windowHeader);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
--headerHeight: 46px;
--headerHeightNarrow: 42px;
}
> button {
height: $height;
width: $height;
.header {
display: flex;
flex-shrink: 0;
background: var(--windowHeader);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
@media (max-width: 500px) {
height: $height-narrow;
width: $height-narrow;
}
}
.headerButton {
height: var(--headerHeight);
width: var(--headerHeight);
> .title {
flex: 1;
line-height: $height;
padding-left: 32px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
@media (max-width: 500px) {
line-height: $height-narrow;
padding-left: 16px;
}
}
> button + .title {
padding-left: 0;
}
}
> .body {
flex: 1;
overflow: auto;
background: var(--panel);
@media (max-width: 500px) {
height: var(--headerHeightNarrow);
width: var(--headerHeightNarrow);
}
}
.title {
flex: 1;
line-height: var(--headerHeight);
padding-left: 32px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
@media (max-width: 500px) {
line-height: var(--headerHeightNarrow);
padding-left: 16px;
}
}
.headerButton + .title {
padding-left: 0;
}
.body {
flex: 1;
overflow: auto;
background: var(--panel);
container-type: size;
}
</style>

View File

@@ -695,6 +695,7 @@ function showReactions(): void {
position: absolute;
bottom: 0;
left: 0;
z-index: 2;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));

View File

@@ -87,7 +87,7 @@ export default defineComponent({
},
async openDrive() {
os.selectDriveFile();
os.selectDriveFile(false);
},
async selectUser() {

View File

@@ -1,13 +1,13 @@
<template>
<div class="vblkjoeq">
<div class="label" @click="focus"><slot name="label"></slot></div>
<div ref="container" class="input" :class="{ inline, disabled, focused }" @mousedown.prevent="show">
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<div>
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
<div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show">
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
v-model="v"
v-adaptive-border
class="select"
:class="$style.inputCore"
:disabled="disabled"
:required="required"
:readonly="readonly"
@@ -18,9 +18,9 @@
>
<slot></slot>
</select>
<div ref="suffixEl" class="suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div>
<div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div>
</div>
<div class="caption"><slot name="caption"></slot></div>
<div :class="$style.caption"><slot name="caption"></slot></div>
<MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
@@ -169,121 +169,116 @@ function show(ev: MouseEvent) {
}
</script>
<style lang="scss" scoped>
.vblkjoeq {
> .label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
<style lang="scss" module>
.label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
&:empty {
display: none;
&:empty {
display: none;
}
}
.caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
&:empty {
display: none;
}
}
.input {
position: relative;
cursor: pointer;
&.inline {
display: inline-block;
margin: 0;
}
&.focused {
> .inputCore {
border-color: var(--accent) !important;
//box-shadow: 0 0 0 4px var(--focus);
}
}
> .caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
&.disabled {
opacity: 0.7;
&:empty {
display: none;
&,
> .inputCore {
cursor: not-allowed !important;
}
}
> .input {
position: relative;
cursor: pointer;
&:hover {
> .select {
border-color: var(--inputBorderHover) !important;
}
}
> .select {
appearance: none;
-webkit-appearance: none;
display: block;
height: v-bind("height + 'px'");
width: 100%;
margin: 0;
padding: 0 12px;
font: inherit;
font-weight: normal;
font-size: 1em;
color: var(--fg);
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 6px;
outline: none;
box-shadow: none;
box-sizing: border-box;
cursor: pointer;
transition: border-color 0.1s ease-out;
pointer-events: none;
user-select: none;
}
> .prefix,
> .suffix {
display: flex;
align-items: center;
position: absolute;
z-index: 1;
top: 0;
padding: 0 12px;
font-size: 1em;
height: v-bind("height + 'px'");
pointer-events: none;
&:empty {
display: none;
}
> * {
display: inline-block;
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
> .prefix {
left: 0;
padding-right: 6px;
}
> .suffix {
right: 0;
padding-left: 6px;
}
&.inline {
display: inline-block;
margin: 0;
}
&.focused {
> select {
border-color: var(--accent) !important;
}
}
&.disabled {
opacity: 0.7;
&, * {
cursor: not-allowed !important;
}
&:hover {
> .inputCore {
border-color: var(--inputBorderHover) !important;
}
}
}
</style>
<style lang="scss" module>
.inputCore {
appearance: none;
-webkit-appearance: none;
display: block;
height: v-bind("height + 'px'");
width: 100%;
margin: 0;
padding: 0 12px;
font: inherit;
font-weight: normal;
font-size: 1em;
color: var(--fg);
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 6px;
outline: none;
box-shadow: none;
box-sizing: border-box;
transition: border-color 0.1s ease-out;
cursor: pointer;
pointer-events: none;
user-select: none;
}
.prefix,
.suffix {
display: flex;
align-items: center;
position: absolute;
z-index: 1;
top: 0;
padding: 0 12px;
font-size: 1em;
height: v-bind("height + 'px'");
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
pointer-events: none;
&:empty {
display: none;
}
}
.prefix {
left: 0;
padding-right: 6px;
}
.suffix {
right: 0;
padding-left: 6px;
}
.chevron {
transition: transform 0.1s ease-out;
}

View File

@@ -3,7 +3,7 @@ import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import { onBeforeUnmount } from 'vue';
import MkSignupServerRules from './MkSignupDialog,rules.vue';
import MkSignupServerRules from './MkSignupDialog.rules.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
export const Empty = {

View File

@@ -1,30 +1,30 @@
<template>
<div class="_panel vjnjpkug">
<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar class="avatar" :user="user" indicator/>
<div class="title">
<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
<p class="username"><MkAcct :user="user"/></p>
<div class="_panel" :class="$style.root">
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<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>
</div>
<span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<div class="description">
<span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
<div :class="$style.description">
<div v-if="user.description" class="mfm">
<Mfm :text="user.description" :author="user" :i="$i"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
<div class="status">
<div>
<p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span>
<div :class="$style.status">
<div :class="$style.statusItem">
<p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ user.notesCount }}</span>
</div>
<div>
<p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span>
<div :class="$style.statusItem">
<p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ user.followingCount }}</span>
</div>
<div>
<p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span>
<div :class="$style.statusItem">
<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ user.followersCount }}</span>
</div>
</div>
<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
<MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
</div>
</template>
@@ -40,99 +40,99 @@ defineProps<{
}>();
</script>
<style lang="scss" scoped>
.vjnjpkug {
<style lang="scss" module>
.root {
position: relative;
}
> .banner {
height: 84px;
background-color: rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
}
.banner {
height: 84px;
background-color: rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
}
> .avatar {
display: block;
position: absolute;
top: 62px;
left: 13px;
z-index: 2;
width: 58px;
height: 58px;
border: solid 4px var(--panel);
}
.avatar {
display: block;
position: absolute;
top: 62px;
left: 13px;
z-index: 2;
width: 58px;
height: 58px;
border: solid 4px var(--panel);
}
> .title {
display: block;
padding: 10px 0 10px 88px;
.title {
display: block;
padding: 10px 0 10px 88px;
}
> .name {
display: inline-block;
margin: 0;
font-weight: bold;
line-height: 16px;
word-break: break-all;
}
.name {
display: inline-block;
margin: 0;
font-weight: bold;
line-height: 16px;
word-break: break-all;
}
> .username {
display: block;
margin: 0;
line-height: 16px;
font-size: 0.8em;
color: var(--fg);
opacity: 0.7;
}
}
> .followed {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 8px;
color: #fff;
background: rgba(0, 0, 0, 0.7);
font-size: 0.7em;
border-radius: 6px;
}
> .description {
padding: 16px;
font-size: 0.8em;
border-top: solid 0.5px var(--divider);
.username {
display: block;
margin: 0;
line-height: 16px;
font-size: 0.8em;
color: var(--fg);
opacity: 0.7;
}
> .mfm {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.followed {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 8px;
color: #fff;
background: rgba(0, 0, 0, 0.7);
font-size: 0.7em;
border-radius: 6px;
}
> .status {
padding: 10px 16px;
border-top: solid 0.5px var(--divider);
.description {
padding: 16px;
font-size: 0.8em;
border-top: solid 0.5px var(--divider);
}
> div {
display: inline-block;
width: 33%;
.mfm {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
> p {
margin: 0;
font-size: 0.7em;
color: var(--fg);
}
.status {
padding: 10px 16px;
border-top: solid 0.5px var(--divider);
}
> span {
font-size: 1em;
color: var(--accent);
}
}
}
.statusItem {
display: inline-block;
width: 33%;
}
> .koudoku-button {
position: absolute;
top: 8px;
right: 8px;
}
.statusItemLabel {
margin: 0;
font-size: 0.7em;
color: var(--fg);
}
.statusItemValue {
font-size: 1em;
color: var(--accent);
}
.follow {
position: absolute !important;
top: 8px;
right: 8px;
}
</style>

View File

@@ -8,7 +8,7 @@
</template>
<template #default="{ items }">
<div class="efvhhmdq">
<div :class="$style.root">
<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
</div>
</template>
@@ -29,8 +29,8 @@ const props = withDefaults(defineProps<{
});
</script>
<style lang="scss" scoped>
.efvhhmdq {
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: var(--margin);

View File

@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { commonHandlers } from '../../.storybook/mocks';
import { userDetailed } from '../../.storybook/fakes';
import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog_Follow,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog_Follow v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
rest.post('/api/users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
rest.post('/api/pinned-users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
],
},
},
} satisfies StoryObj<typeof MkUserSetupDialog_Follow>;

View File

@@ -0,0 +1,63 @@
<template>
<div class="_gaps">
<div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div>
<MkFolder :default-open="true">
<template #label>{{ i18n.ts.recommended }}</template>
<MkPagination :pagination="pinnedUsers">
<template #default="{ items }">
<div :class="$style.users">
<XUser v-for="item in items" :key="item.id" :user="item"/>
</div>
</template>
</MkPagination>
</MkFolder>
<MkFolder :default-open="true">
<template #label>{{ i18n.ts.popularUsers }}</template>
<MkPagination :pagination="popularUsers">
<template #default="{ items }">
<div :class="$style.users">
<XUser v-for="item in items" :key="item.id" :user="item"/>
</div>
</template>
</MkPagination>
</MkFolder>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import XUser from '@/components/MkUserSetupDialog.User.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
import { $i } from '@/account';
import MkPagination from '@/components/MkPagination.vue';
const emit = defineEmits<{
(ev: 'done'): void;
}>();
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'local',
sort: '+follower',
} };
</script>
<style lang="scss" module>
.users {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
grid-gap: var(--margin);
justify-content: center;
}
</style>

View File

@@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog_Profile,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog_Profile v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkUserSetupDialog_Profile>;

View File

@@ -0,0 +1,101 @@
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._initialAccountSetting.theseSettingsCanEditLater }}</MkInfo>
<FormSlot>
<template #label>{{ i18n.ts.avatar }}</template>
<div v-adaptive-bg :class="$style.avatarSection" class="_panel">
<MkAvatar :class="$style.avatar" :user="$i" @click="setAvatar"/>
<div style="margin-top: 16px;">
<MkButton primary rounded inline @click="setAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
</div>
</div>
</FormSlot>
<MkInput v-model="name" :max="30" manual-save data-cy-user-setup-user-name>
<template #label>{{ i18n.ts._profile.name }}</template>
</MkInput>
<MkTextarea v-model="description" :max="500" tall manual-save data-cy-user-setup-user-description>
<template #label>{{ i18n.ts._profile.description }}</template>
</MkTextarea>
<MkInfo>{{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}</MkInfo>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSlot from '@/components/form/slot.vue';
import MkInfo from '@/components/MkInfo.vue';
import { chooseFileFromPc } from '@/scripts/select-file';
import * as os from '@/os';
import { $i } from '@/account';
const emit = defineEmits<{
(ev: 'done'): void;
}>();
const name = ref('');
const description = ref('');
watch(name, () => {
os.apiWithDialog('i/update', {
// 空文字列をnullにしたいので??は使うな
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null,
});
});
watch(description, () => {
os.apiWithDialog('i/update', {
// 空文字列をnullにしたいので??は使うな
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
description: description.value || null,
});
});
function setAvatar(ev) {
chooseFileFromPc(false).then(async (files) => {
const file = files[0];
let originalOrCropped = file;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('cropImageAsk'),
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImage(file, {
aspectRatio: 1,
});
}
const i = await os.apiWithDialog('i/update', {
avatarId: originalOrCropped.id,
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
});
}
</script>
<style lang="scss" module>
.avatarSection {
text-align: center;
padding: 20px;
}
.avatar {
width: 100px;
height: 100px;
}
</style>

View File

@@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { userDetailed } from '../../.storybook/fakes';
import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog_User,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog_User v-bind="props" />',
};
},
args: {
user: userDetailed(),
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkUserSetupDialog_User>;

View File

@@ -0,0 +1,101 @@
<template>
<div v-adaptive-bg class="_panel" style="position: relative;">
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.title">
<div :class="$style.name"><MkUserName :user="user" :nowrap="false"/></div>
<p :class="$style.username"><MkAcct :user="user"/></p>
</div>
<div :class="$style.description">
<div v-if="user.description" :class="$style.mfm">
<Mfm :text="user.description" :author="user" :i="$i"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
<div :class="$style.footer">
<MkButton v-if="!isFollowing" primary gradate rounded full @click="follow"><i class="ti ti-plus"></i> {{ i18n.ts.follow }}</MkButton>
<div v-else style="opacity: 0.7; text-align: center;">{{ i18n.ts.youFollowing }} <i class="ti ti-check"></i></div>
</div>
</div>
</template>
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import * as os from '@/os';
const props = defineProps<{
user: misskey.entities.UserDetailed;
}>();
const isFollowing = ref(false);
async function follow() {
isFollowing.value = true;
os.api('following/create', {
userId: props.user.id,
});
}
</script>
<style lang="scss" module>
.banner {
height: 60px;
background-color: rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
}
.avatar {
display: block;
position: absolute;
top: 30px;
left: 13px;
z-index: 2;
width: 58px;
height: 58px;
border: solid 4px var(--panel);
}
.title {
display: block;
padding: 10px 0 10px 88px;
}
.name {
display: inline-block;
margin: 0;
font-weight: bold;
line-height: 16px;
word-break: break-all;
}
.username {
display: block;
margin: 0;
line-height: 16px;
font-size: 0.8em;
color: var(--fg);
opacity: 0.7;
}
.description {
padding: 0 16px 16px 88px;
font-size: 0.9em;
}
.mfm {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.footer {
border-top: solid 0.5px var(--divider);
padding: 16px;
}
</style>

View File

@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { commonHandlers } from '../../.storybook/mocks';
import { userDetailed } from '../../.storybook/fakes';
import MkUserSetupDialog from './MkUserSetupDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
rest.post('/api/users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
rest.post('/api/pinned-users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
],
},
},
} satisfies StoryObj<typeof MkUserSetupDialog>;

View File

@@ -0,0 +1,145 @@
<template>
<MkModalWindow
ref="dialog"
:width="500"
:height="550"
data-cy-user-setup
@close="close(true)"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.initialAccountSetting }}</template>
<div style="overflow-x: clip;">
<Transition
mode="out-in"
:enter-active-class="$style.transition_x_enterActive"
:leave-active-class="$style.transition_x_leaveActive"
:enter-from-class="$style.transition_x_enterFrom"
:leave-to-class="$style.transition_x_leaveTo"
>
<template v-if="page === 0">
<div :class="$style.centerPage">
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div>
<div>{{ i18n.ts._initialAccountSetting.letsStartAccountSetup }}</div>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 1">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :margin-min="20" :margin-max="28">
<XProfile/>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 2">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :margin-min="20" :margin-max="28">
<XFollow/>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 3">
<div :class="$style.centerPage">
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div>
<div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div>
<MkPushNotificationAllowButton primary show-only-to-register style="margin: 0 auto;"/>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 4">
<div :class="$style.centerPage">
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
<I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;">
<template #name>{{ instance.name ?? host }}</template>
<template #link>
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</Transition>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, shallowRef, watch } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
import XFollow from '@/components/MkUserSetupDialog.Follow.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store';
import * as os from '@/os';
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const page = ref(defaultStore.state.accountSetupWizard);
watch(page, () => {
defaultStore.set('accountSetupWizard', page.value);
});
async function close(skip: boolean) {
if (skip) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._initialAccountSetting.skipAreYouSure,
});
if (canceled) return;
}
dialog.value.close();
defaultStore.set('accountSetupWizard', -1);
}
</script>
<style lang="scss" module>
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.centerPage {
display: flex;
justify-content: center;
align-items: center;
height: 100cqh;
padding-bottom: 30px;
box-sizing: border-box;
}
</style>

View File

@@ -2,11 +2,11 @@
<div :class="$style.root">
<template v-if="edit">
<header :class="$style['edit-header']">
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select">
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select>
<template #label>{{ i18n.ts.selectWidget }}</template>
<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option>
</MkSelect>
<MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
</header>
<Sortable

View File

@@ -29,7 +29,7 @@
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
</span>
</div>
<div v-container :class="$style.content">
<div :class="$style.content">
<slot></slot>
</div>
</div>
@@ -541,7 +541,7 @@ defineExpose({
flex: 1;
overflow: auto;
background: var(--panel);
container-type: inline-size;
container-type: size;
}
$handleSize: 8px;

View File

@@ -41,3 +41,35 @@ export const Detail = {
detail: true,
},
} satisfies StoryObj<typeof MkAcct>;
export const Long = {
...Default,
args: {
...Default.args,
user: {
...userDetailed(),
username: 'the_quick_brown_fox_jumped_over_the_lazy_dog',
host: 'misskey.example',
},
},
decorators: [
() => ({
template: '<div style="width: 360px;"><story/></div>',
}),
],
} satisfies StoryObj<typeof MkAcct>;
export const VeryLong = {
...Default,
args: {
...Default.args,
user: {
...userDetailed(),
username: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc',
host: 'the.quick.brown.fox.jumped.over.the.lazy.dog.very.long.hostname.nostr.example',
},
},
decorators: [
() => ({
template: '<div style="width: 360px;"><story/></div>',
}),
],
} satisfies StoryObj<typeof MkAcct>;

View File

@@ -1,5 +1,9 @@
<template>
<span>
<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :min-scale="2 / 3">
<span>@{{ user.username }}</span>
<span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
</MkCondensedLine>
<span v-else>
<span>@{{ user.username }}</span>
<span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
</span>
@@ -8,6 +12,7 @@
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { toUnicode } from 'punycode/';
import MkCondensedLine from './MkCondensedLine.vue';
import { host as hostRaw } from '@/config';
import { defaultStore } from '@/store';

View File

@@ -2,7 +2,7 @@
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
<img :class="$style.inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
@@ -154,24 +154,6 @@ watch(() => props.user.avatarBlurhash, () => {
padding: 50%;
pointer-events: none;
&.mask {
-webkit-mask:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') center / 50% 50%,
linear-gradient(#fff, #fff);
-webkit-mask-composite: destination-out, source-over;
mask:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%,
linear-gradient(#fff, #fff); // polyfill of `image(#fff)`
> .earLeft {
animation: eartightleft 6s infinite;
}
> .earRight {
animation: eartightright 6s infinite;
}
}
> .earLeft,
> .earRight {
contain: strict;
@@ -222,7 +204,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(37.5deg) skew(30deg);
&, &::after {
border-radius: 0 75% 75%;
border-radius: 25% 75% 75%;
}
> .layer {
@@ -251,7 +233,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(-37.5deg) skew(-30deg);
&, &::after {
border-radius: 75% 0 75% 75%;
border-radius: 75% 25% 75% 75%;
}
> .layer {

View File

@@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import MkCondensedLine from './MkCondensedLine.vue';
export const Default = {
render(args) {
return {
components: {
MkCondensedLine,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCondensedLine>{{ props.text }}</MkCondensedLine>',
};
},
args: {
text: 'This is a condensed line.',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCondensedLine>;
export const ContainerIs100px = {
...Default,
decorators: [
() => ({
template: '<div style="width: 100px;"><story/></div>',
}),
],
} satisfies StoryObj<typeof MkCondensedLine>;

View File

@@ -0,0 +1,65 @@
<template>
<span :class="$style.container">
<span ref="content" :class="$style.content">
<slot/>
</span>
</span>
</template>
<script lang="ts">
interface Props {
readonly minScale?: number;
}
const contentSymbol = Symbol();
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement;
const props: Required<Props> = content[contentSymbol];
const container = content.parentElement as HTMLSpanElement;
const contentWidth = content.getBoundingClientRect().width;
const containerWidth = container.getBoundingClientRect().width;
container.style.transform = `scaleX(${Math.max(props.minScale, Math.min(1, containerWidth / contentWidth))})`;
}
});
</script>
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = withDefaults(defineProps<Props>(), {
minScale: 0,
});
const content = ref<HTMLSpanElement>();
watch(content, (value, oldValue) => {
if (oldValue) {
delete oldValue[contentSymbol];
observer.unobserve(oldValue);
if (oldValue.parentElement) {
observer.unobserve(oldValue.parentElement);
}
}
if (value) {
value[contentSymbol] = props;
observer.observe(value);
if (value.parentElement) {
observer.observe(value.parentElement);
}
}
});
</script>
<style module lang="scss">
.container {
display: inline-block;
max-width: 100%;
transform-origin: 0;
}
.content {
display: inline-block;
white-space: nowrap;
}
</style>

View File

@@ -156,7 +156,7 @@ onUnmounted(() => {
}
&.thin {
--height: 42px;
--height: 40px;
> .buttons {
> .button {

View File

@@ -5,6 +5,7 @@ import MkA from './global/MkA.vue';
import MkAcct from './global/MkAcct.vue';
import MkAvatar from './global/MkAvatar.vue';
import MkEmoji from './global/MkEmoji.vue';
import MkCondensedLine from './global/MkCondensedLine.vue';
import MkCustomEmoji from './global/MkCustomEmoji.vue';
import MkUserName from './global/MkUserName.vue';
import MkEllipsis from './global/MkEllipsis.vue';
@@ -33,6 +34,7 @@ export const components = {
MkAcct: MkAcct,
MkAvatar: MkAvatar,
MkEmoji: MkEmoji,
MkCondensedLine: MkCondensedLine,
MkCustomEmoji: MkCustomEmoji,
MkUserName: MkUserName,
MkEllipsis: MkEllipsis,
@@ -55,6 +57,7 @@ declare module '@vue/runtime-core' {
MkAcct: typeof MkAcct;
MkAvatar: typeof MkAvatar;
MkEmoji: typeof MkEmoji;
MkCondensedLine: typeof MkCondensedLine;
MkCustomEmoji: typeof MkCustomEmoji;
MkUserName: typeof MkUserName;
MkEllipsis: typeof MkEllipsis;

View File

@@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
'canSearchNotes',
'canHideAds',
'driveCapacityMb',
'alwaysMarkNsfw',
'pinLimit',
'antennaLimit',
'wordMuteLimit',

View File

@@ -1,21 +0,0 @@
import { Directive } from 'vue';
const map = new WeakMap<HTMLElement, ResizeObserver>();
export default {
mounted(el: HTMLElement, binding, vn) {
const ro = new ResizeObserver((entries, observer) => {
el.style.setProperty('--containerHeight', el.offsetHeight + 'px');
});
ro.observe(el);
map.set(el, ro);
},
unmounted(el, binding, vn) {
const ro = map.get(el);
if (ro) {
ro.disconnect();
map.delete(el);
}
},
} as Directive;

View File

@@ -11,7 +11,6 @@ import clickAnime from './click-anime';
import panel from './panel';
import adaptiveBorder from './adaptive-border';
import adaptiveBg from './adaptive-bg';
import container from './container';
export default function(app: App) {
for (const [key, value] of Object.entries(directives)) {
@@ -32,5 +31,4 @@ export const directives = {
'panel': panel,
'adaptive-border': adaptiveBorder,
'adaptive-bg': adaptiveBg,
'container': container,
};

View File

@@ -343,6 +343,16 @@ if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;
if (defaultStore.state.accountSetupWizard !== -1) {
// このウィザードが実装される前に登録したユーザーには表示させないため
// TODO: そのうち消す
if (Date.now() - new Date($i.createdAt).getTime() < 1000 * 60 * 60 * 24) {
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
} else {
defaultStore.set('accountSetupWizard', -1);
}
}
if ($i.isDeleted) {
alert({
type: 'warning',
@@ -449,7 +459,7 @@ if ($i) {
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3)))) {
if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
}

View File

@@ -19,6 +19,7 @@ import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard';
import { showMovedDialog } from './scripts/show-moved-dialog';
import { DriveFile } from 'misskey-js/built/entities';
export const openingWindowsCount = ref(0);
@@ -420,7 +421,7 @@ export async function selectUser(opts: { includeSelf?: boolean } = {}) {
});
}
export async function selectDriveFile(multiple: boolean) {
export async function selectDriveFile(multiple: boolean): Promise<DriveFile[]> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
@@ -428,7 +429,7 @@ export async function selectDriveFile(multiple: boolean) {
}, {
done: files => {
if (files) {
resolve(multiple ? files : files[0]);
resolve(files);
}
},
}, 'closed');

View File

@@ -141,6 +141,9 @@ const patronsWithIcon = [{
}, {
name: 'ぺんぎん',
icon: 'https://misskey-hub.net/patrons/6a652e0534ff4cb1836e7ce4968d76a7.jpg',
}, {
name: 'かみらえっと',
icon: 'https://misskey-hub.net/patrons/be1326bda7d940a482f3758ffd9ffaf6.jpg',
}];
const patrons = [
@@ -234,6 +237,7 @@ const patrons = [
'けそ',
'ずも',
'binvinyl',
'渡志郎',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@@ -210,7 +210,7 @@
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>
@@ -231,6 +231,26 @@
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])">
<template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template>
<template #suffix>
<span v-if="role.policies.alwaysMarkNsfw.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.alwaysMarkNsfw.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.alwaysMarkNsfw)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.alwaysMarkNsfw.value" :disabled="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>

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