Merge branch 'develop' into mkjs-n
This commit is contained in:
13
packages/backend/migration/1683328299359-channelArchive.js
Normal file
13
packages/backend/migration/1683328299359-channelArchive.js
Normal 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"`);
|
||||
}
|
||||
}
|
@@ -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"`);
|
||||
}
|
||||
}
|
@@ -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`);
|
||||
}
|
||||
}
|
@@ -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",
|
||||
|
@@ -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(
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@@ -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', {
|
||||
|
@@ -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)),
|
||||
|
178
packages/backend/src/core/SearchService.ts
Normal file
178
packages/backend/src/core/SearchService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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'),
|
||||
|
@@ -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 {
|
||||
|
@@ -70,6 +70,12 @@ export class Channel {
|
||||
})
|
||||
public color: string;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isArchived: boolean;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
|
@@ -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,
|
||||
})
|
||||
|
@@ -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,
|
||||
|
@@ -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',
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
@@ -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));
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
@@ -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));
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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],
|
||||
|
@@ -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),
|
||||
|
@@ -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();
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
|
@@ -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) {
|
||||
|
@@ -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));
|
||||
|
||||
|
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@@ -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 })[];
|
@@ -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;
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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}`);
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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, {
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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);
|
||||
});
|
||||
|
||||
|
49
packages/backend/test/unit/misc/check-word-mute.ts
Normal file
49
packages/backend/test/unit/misc/check-word-mute.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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())
|
||||
|
@@ -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();
|
||||
|
@@ -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",
|
||||
|
@@ -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 {
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
@@ -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));
|
||||
|
@@ -87,7 +87,7 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
async openDrive() {
|
||||
os.selectDriveFile();
|
||||
os.selectDriveFile(false);
|
||||
},
|
||||
|
||||
async selectUser() {
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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 = {
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
|
@@ -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>;
|
@@ -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>
|
@@ -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>;
|
101
packages/frontend/src/components/MkUserSetupDialog.Profile.vue
Normal file
101
packages/frontend/src/components/MkUserSetupDialog.Profile.vue
Normal 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>
|
@@ -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>;
|
101
packages/frontend/src/components/MkUserSetupDialog.User.vue
Normal file
101
packages/frontend/src/components/MkUserSetupDialog.User.vue
Normal 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>
|
@@ -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>;
|
145
packages/frontend/src/components/MkUserSetupDialog.vue
Normal file
145
packages/frontend/src/components/MkUserSetupDialog.vue
Normal 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>
|
@@ -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
|
||||
|
@@ -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;
|
||||
|
@@ -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>;
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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>;
|
65
packages/frontend/src/components/global/MkCondensedLine.vue
Normal file
65
packages/frontend/src/components/global/MkCondensedLine.vue
Normal 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>
|
@@ -156,7 +156,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
&.thin {
|
||||
--height: 42px;
|
||||
--height: 40px;
|
||||
|
||||
> .buttons {
|
||||
> .button {
|
||||
|
@@ -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;
|
||||
|
@@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
|
||||
'canSearchNotes',
|
||||
'canHideAds',
|
||||
'driveCapacityMb',
|
||||
'alwaysMarkNsfw',
|
||||
'pinLimit',
|
||||
'antennaLimit',
|
||||
'wordMuteLimit',
|
||||
|
@@ -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;
|
@@ -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,
|
||||
};
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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'));
|
||||
|
@@ -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
Reference in New Issue
Block a user