Merge branch 'develop' into mkusername-empty
|
@ -1,25 +1,23 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"dynamicImport": true,
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"dynamicImport": true,
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"experimental": {
|
||||
"keepImportAssertions": true
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["*"]
|
||||
},
|
||||
"target": "es2021"
|
||||
},
|
||||
"minify": false
|
||||
},
|
||||
"minify": false
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 162 KiB |
10
packages/backend/check_connect.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {loadConfig} from './built/config.js';
|
||||
import {createRedisConnection} from "./built/redis.js";
|
||||
|
||||
const config = loadConfig();
|
||||
const redis = createRedisConnection(config);
|
||||
|
||||
redis.on('connect', () => redis.disconnect());
|
||||
redis.on('error', (e) => {
|
||||
throw e;
|
||||
});
|
|
@ -10,19 +10,11 @@ export class dropGroup1676434944993 {
|
|||
await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum" AS ENUM('home', 'all', 'users', 'list')`);
|
||||
await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum" USING "src"::"text"::"public"."antenna_src_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."antenna_src_enum_old"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app')`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "emailNotificationTypes" SET DEFAULT '["follow","receiveFollowRequest"]'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "emailNotificationTypes" SET DEFAULT '["follow", "receiveFollowRequest", "groupInvited"]'`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list', 'group')`);
|
||||
await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum_old" USING "src"::"text"::"public"."antenna_src_enum_old"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."antenna_src_enum"`);
|
||||
|
|
|
@ -4,6 +4,6 @@ export class ad1676438468213 {
|
|||
await queryRunner.query(`ALTER TABLE "ad" ADD "startsAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
|
||||
}
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "startsAt"`);
|
||||
await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "startsAt"`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,48 +7,62 @@
|
|||
"start": "node ./built/index.js",
|
||||
"start:test": "NODE_ENV=test node ./built/index.js",
|
||||
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
|
||||
"build:swc": "swc src -d built -D",
|
||||
"check:connect": "node ./check_connect.js",
|
||||
"build": "swc src -d built -D",
|
||||
"watch:swc": "swc src -d built -D -w",
|
||||
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
|
||||
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"watch": "node watch.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"eslint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand --detectOpenHandles",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand --detectOpenHandles",
|
||||
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
|
||||
"test": "pnpm jest",
|
||||
"test-and-coverage": "pnpm jest-and-coverage"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-android-arm64": "^1.3.11",
|
||||
"@swc/core-darwin-arm64": "^1.3.36",
|
||||
"@swc/core-darwin-x64": "^1.3.36",
|
||||
"@swc/core-linux-arm-gnueabihf": "^1.3.36",
|
||||
"@swc/core-linux-arm64-gnu": "^1.3.36",
|
||||
"@swc/core-linux-arm64-musl": "^1.3.36",
|
||||
"@swc/core-linux-x64-gnu": "^1.3.36",
|
||||
"@swc/core-linux-x64-musl": "^1.3.36",
|
||||
"@swc/core-win32-arm64-msvc": "^1.3.36",
|
||||
"@swc/core-win32-ia32-msvc": "^1.3.36",
|
||||
"@swc/core-win32-x64-msvc": "^1.3.36",
|
||||
"@tensorflow/tfjs": "4.2.0",
|
||||
"@tensorflow/tfjs-node": "4.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "4.11.1",
|
||||
"@bull-board/fastify": "4.11.1",
|
||||
"@bull-board/ui": "4.11.1",
|
||||
"@bull-board/api": "4.12.1",
|
||||
"@bull-board/fastify": "4.12.1",
|
||||
"@bull-board/ui": "4.12.1",
|
||||
"@discordapp/twemoji": "14.0.2",
|
||||
"@fastify/accepts": "4.1.0",
|
||||
"@fastify/cookie": "8.3.0",
|
||||
"@fastify/cors": "8.2.0",
|
||||
"@fastify/http-proxy": "8.4.0",
|
||||
"@fastify/multipart": "7.4.0",
|
||||
"@fastify/static": "6.8.0",
|
||||
"@fastify/multipart": "7.4.1",
|
||||
"@fastify/static": "6.9.0",
|
||||
"@fastify/view": "7.4.1",
|
||||
"@nestjs/common": "9.3.7",
|
||||
"@nestjs/core": "9.3.7",
|
||||
"@nestjs/testing": "9.3.7",
|
||||
"@nestjs/common": "9.3.9",
|
||||
"@nestjs/core": "9.3.9",
|
||||
"@nestjs/testing": "9.3.9",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "10.0.2",
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.36",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
"autwh": "0.1.0",
|
||||
"aws-sdk": "2.1295.0",
|
||||
"aws-sdk": "2.1318.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.4",
|
||||
"bull": "4.10.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bull": "4.10.4",
|
||||
"cacheable-lookup": "6.1.0",
|
||||
"cbor": "8.1.0",
|
||||
"chalk": "5.2.0",
|
||||
|
@ -60,12 +74,13 @@
|
|||
"date-fns": "2.29.3",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"escape-regexp": "0.0.1",
|
||||
"fastify": "4.12.0",
|
||||
"fastify": "4.13.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "18.2.0",
|
||||
"file-type": "18.2.1",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
"form-data": "4.0.0",
|
||||
"got": "12.5.3",
|
||||
"happy-dom": "^8.7.0",
|
||||
"hpagent": "1.2.0",
|
||||
"ioredis": "4.28.5",
|
||||
"ip-cidr": "3.1.0",
|
||||
|
@ -85,6 +100,7 @@
|
|||
"nsfwjs": "2.4.2",
|
||||
"oauth": "0.10.0",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "^9.0.2",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.9.0",
|
||||
"private-ip": "3.0.0",
|
||||
|
@ -104,15 +120,14 @@
|
|||
"rss-parser": "3.12.0",
|
||||
"rxjs": "7.8.0",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.9.0",
|
||||
"sanitize-html": "2.10.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"semver": "7.3.8",
|
||||
"sharp": "0.31.3",
|
||||
"speakeasy": "2.0.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"systeminformation": "5.17.8",
|
||||
"systeminformation": "5.17.9",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
|
@ -126,14 +141,12 @@
|
|||
"vary": "1.1.2",
|
||||
"web-push": "3.5.0",
|
||||
"websocket": "1.0.34",
|
||||
"ws": "8.12.0",
|
||||
"ws": "8.12.1",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.4.2",
|
||||
"@jest/globals": "29.4.3",
|
||||
"@redocly/openapi-core": "1.0.0-beta.123",
|
||||
"@swc/cli": "0.1.61",
|
||||
"@swc/core": "1.3.34",
|
||||
"@swc/jest": "0.2.24",
|
||||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.1",
|
||||
|
@ -151,7 +164,7 @@
|
|||
"@types/jsonld": "1.5.8",
|
||||
"@types/jsrsasign": "10.5.5",
|
||||
"@types/mime-types": "2.1.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/node": "18.14.0",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/oauth": "0.9.1",
|
||||
|
@ -167,7 +180,6 @@
|
|||
"@types/semver": "7.3.13",
|
||||
"@types/sharp": "0.31.1",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/speakeasy": "2.0.7",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/unzipper": "0.10.5",
|
||||
|
@ -176,13 +188,13 @@
|
|||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.51.0",
|
||||
"@typescript-eslint/parser": "5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.52.0",
|
||||
"@typescript-eslint/parser": "5.52.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.33.0",
|
||||
"eslint": "8.34.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"execa": "6.1.0",
|
||||
"jest": "29.4.2",
|
||||
"jest-mock": "29.4.2"
|
||||
"jest": "29.4.3",
|
||||
"jest-mock": "29.4.3"
|
||||
}
|
||||
}
|
||||
}
|
8
packages/backend/src/@types/redis-lock.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
declare module 'redis-lock' {
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise<void>) => void;
|
||||
function redisLock(client: Redis.Redis, retryDelay: number): Lock;
|
||||
|
||||
export = redisLock;
|
||||
}
|
|
@ -12,7 +12,7 @@ const retryDelay = 100;
|
|||
|
||||
@Injectable()
|
||||
export class AppLockService {
|
||||
private lock: (key: string, timeout?: number) => Promise<() => void>;
|
||||
private lock: (key: string, timeout?: number, _?: (() => Promise<void>) | undefined) => Promise<() => void>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as parse5 from 'parse5';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { Window } from 'happy-dom';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { intersperse } from '@/misc/prelude/array.js';
|
||||
|
@ -235,7 +235,7 @@ export class MfmService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { window } = new JSDOM('');
|
||||
const { window } = new Window();
|
||||
|
||||
const doc = window.document;
|
||||
|
||||
|
@ -300,7 +300,7 @@ export class MfmService {
|
|||
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `${this.config.url}/tags/${node.props.hashtag}`;
|
||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
return a;
|
||||
|
@ -326,7 +326,7 @@ export class MfmService {
|
|||
|
||||
link: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.setAttribute('href', node.props.url);
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
@ -335,7 +335,7 @@ export class MfmService {
|
|||
const a = doc.createElement('a');
|
||||
const { username, host, acct } = node.props;
|
||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
|
||||
a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
|
||||
a.className = 'u-url mention';
|
||||
a.textContent = acct;
|
||||
return a;
|
||||
|
@ -360,14 +360,14 @@ export class MfmService {
|
|||
|
||||
url: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url;
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `https://www.google.com/search?q=${node.props.query}`;
|
||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
return a;
|
||||
},
|
||||
|
|
|
@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
// Defined also packages/sw/types.ts#L13
|
||||
type pushNotificationsTypes = {
|
||||
type PushNotificationsTypes = {
|
||||
'notification': Packed<'Notification'>;
|
||||
'unreadAntennaNote': {
|
||||
antenna: { id: string, name: string };
|
||||
|
@ -22,8 +22,8 @@ type pushNotificationsTypes = {
|
|||
};
|
||||
|
||||
// Reduce length because push message servers have character limits
|
||||
function truncateBody<T extends keyof pushNotificationsTypes>(type: T, body: pushNotificationsTypes[T]): pushNotificationsTypes[T] {
|
||||
if (body === undefined) return body;
|
||||
function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: PushNotificationsTypes[T]): PushNotificationsTypes[T] {
|
||||
if (typeof body !== 'object') return body;
|
||||
|
||||
return {
|
||||
...body,
|
||||
|
@ -56,7 +56,7 @@ export class PushNotificationService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {
|
||||
public async pushNotification<T extends keyof PushNotificationsTypes>(userId: string, type: T, body: PushNotificationsTypes[T]) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
||||
|
|
|
@ -450,9 +450,11 @@ export class ApInboxService {
|
|||
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: actor.id });
|
||||
if (user.isDeleted) {
|
||||
this.logger.info('skip: already deleted');
|
||||
const user = await this.usersRepository.findOneBy({ id: actor.id });
|
||||
if (user == null) {
|
||||
return 'skip: actor not found';
|
||||
} else if (user.isDeleted) {
|
||||
return 'skip: already deleted';
|
||||
}
|
||||
|
||||
const job = await this.queueService.createDeleteAccountJob(actor);
|
||||
|
|
|
@ -28,6 +28,101 @@ type PrivateKey = {
|
|||
keyId: string;
|
||||
};
|
||||
|
||||
export class ApRequestCreator {
|
||||
static createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
|
||||
const u = new URL(args.url);
|
||||
const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
|
||||
|
||||
const request: Request = {
|
||||
url: u.href,
|
||||
method: 'POST',
|
||||
headers: this.#objectAssignWithLcKey({
|
||||
'Date': new Date().toUTCString(),
|
||||
'Host': u.host,
|
||||
'Content-Type': 'application/activity+json',
|
||||
'Digest': digestHeader,
|
||||
}, args.additionalHeaders),
|
||||
};
|
||||
|
||||
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString: result.signingString,
|
||||
signature: result.signature,
|
||||
signatureHeader: result.signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
|
||||
const u = new URL(args.url);
|
||||
|
||||
const request: Request = {
|
||||
url: u.href,
|
||||
method: 'GET',
|
||||
headers: this.#objectAssignWithLcKey({
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
'Date': new Date().toUTCString(),
|
||||
'Host': new URL(args.url).host,
|
||||
}, args.additionalHeaders),
|
||||
};
|
||||
|
||||
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString: result.signingString,
|
||||
signature: result.signature,
|
||||
signatureHeader: result.signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
|
||||
const signingString = this.#genSigningString(request, includeHeaders);
|
||||
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
|
||||
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
|
||||
|
||||
request.headers = this.#objectAssignWithLcKey(request.headers, {
|
||||
Signature: signatureHeader,
|
||||
});
|
||||
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
|
||||
delete request.headers['host'];
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString,
|
||||
signature,
|
||||
signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
static #genSigningString(request: Request, includeHeaders: string[]): string {
|
||||
request.headers = this.#lcObjectKey(request.headers);
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
for (const key of includeHeaders.map(x => x.toLowerCase())) {
|
||||
if (key === '(request-target)') {
|
||||
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
|
||||
} else {
|
||||
results.push(`${key}: ${request.headers[key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
static #lcObjectKey(src: Record<string, string>): Record<string, string> {
|
||||
const dst: Record<string, string> = {};
|
||||
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
|
||||
return dst;
|
||||
}
|
||||
|
||||
static #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
|
||||
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApRequestService {
|
||||
private logger: Logger;
|
||||
|
@ -44,112 +139,13 @@ export class ApRequestService {
|
|||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
|
||||
const u = new URL(args.url);
|
||||
const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
|
||||
|
||||
const request: Request = {
|
||||
url: u.href,
|
||||
method: 'POST',
|
||||
headers: this.objectAssignWithLcKey({
|
||||
'Date': new Date().toUTCString(),
|
||||
'Host': u.host,
|
||||
'Content-Type': 'application/activity+json',
|
||||
'Digest': digestHeader,
|
||||
}, args.additionalHeaders),
|
||||
};
|
||||
|
||||
const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString: result.signingString,
|
||||
signature: result.signature,
|
||||
signatureHeader: result.signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
|
||||
const u = new URL(args.url);
|
||||
|
||||
const request: Request = {
|
||||
url: u.href,
|
||||
method: 'GET',
|
||||
headers: this.objectAssignWithLcKey({
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
'Date': new Date().toUTCString(),
|
||||
'Host': new URL(args.url).host,
|
||||
}, args.additionalHeaders),
|
||||
};
|
||||
|
||||
const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString: result.signingString,
|
||||
signature: result.signature,
|
||||
signatureHeader: result.signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
|
||||
const signingString = this.genSigningString(request, includeHeaders);
|
||||
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
|
||||
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
|
||||
|
||||
request.headers = this.objectAssignWithLcKey(request.headers, {
|
||||
Signature: signatureHeader,
|
||||
});
|
||||
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
|
||||
delete request.headers['host'];
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString,
|
||||
signature,
|
||||
signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private genSigningString(request: Request, includeHeaders: string[]): string {
|
||||
request.headers = this.lcObjectKey(request.headers);
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
for (const key of includeHeaders.map(x => x.toLowerCase())) {
|
||||
if (key === '(request-target)') {
|
||||
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
|
||||
} else {
|
||||
results.push(`${key}: ${request.headers[key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private lcObjectKey(src: Record<string, string>): Record<string, string> {
|
||||
const dst: Record<string, string> = {};
|
||||
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
|
||||
return dst;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
|
||||
return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async signedPost(user: { id: User['id'] }, url: string, object: any) {
|
||||
const body = JSON.stringify(object);
|
||||
|
||||
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
||||
|
||||
const req = this.createSignedPost({
|
||||
const req = ApRequestCreator.createSignedPost({
|
||||
key: {
|
||||
privateKeyPem: keypair.privateKey,
|
||||
keyId: `${this.config.url}/users/${user.id}#main-key`,
|
||||
|
@ -176,7 +172,7 @@ export class ApRequestService {
|
|||
public async signedGet(url: string, user: { id: User['id'] }) {
|
||||
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
||||
|
||||
const req = this.createSignedGet({
|
||||
const req = ApRequestCreator.createSignedGet({
|
||||
key: {
|
||||
privateKeyPem: keypair.privateKey,
|
||||
keyId: `${this.config.url}/users/${user.id}#main-key`,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
export type obj = { [x: string]: any };
|
||||
export type Obj = { [x: string]: any };
|
||||
export type ApObject = IObject | string | (IObject | string)[];
|
||||
|
||||
export interface IObject {
|
||||
'@context'?: string | string[] | obj | obj[];
|
||||
'@context'?: string | string[] | Obj | Obj[];
|
||||
type: string | string[];
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
|
|
|
@ -94,13 +94,6 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
}),
|
||||
reaction: notification.reaction,
|
||||
} : {}),
|
||||
...(notification.type === 'pollVote' ? { // TODO: そのうち消す
|
||||
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
|
||||
detail: true,
|
||||
_hint_: options._hintForEachNotes_,
|
||||
}),
|
||||
choice: notification.choice,
|
||||
} : {}),
|
||||
...(notification.type === 'pollEnded' ? {
|
||||
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
|
||||
detail: true,
|
||||
|
|
|
@ -25,14 +25,7 @@ export class RoleEntityService {
|
|||
public async pack(
|
||||
src: Role['id'] | Role,
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
options?: {
|
||||
detail?: boolean;
|
||||
},
|
||||
) {
|
||||
const opts = Object.assign({
|
||||
detail: true,
|
||||
}, options);
|
||||
|
||||
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const assigns = await this.roleAssignmentsRepository.findBy({
|
||||
|
@ -65,9 +58,6 @@ export class RoleEntityService {
|
|||
canEditMembersByModerator: role.canEditMembersByModerator,
|
||||
policies: policies,
|
||||
usersCount: assigns.length,
|
||||
...(opts.detail ? {
|
||||
users: this.userEntityService.packMany(assigns.map(x => x.userId), me),
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -75,11 +65,8 @@ export class RoleEntityService {
|
|||
public packMany(
|
||||
roles: any[],
|
||||
me: { id: User['id'] },
|
||||
options?: {
|
||||
detail?: boolean;
|
||||
},
|
||||
) {
|
||||
return Promise.all(roles.map(x => this.pack(x, me, options)));
|
||||
return Promise.all(roles.map(x => this.pack(x, me)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm';
|
||||
import { notificationTypes } from '@/types.js';
|
||||
import { notificationTypes, obsoleteNotificationTypes } from '@/types.js';
|
||||
import { id } from '../id.js';
|
||||
import { User } from './User.js';
|
||||
import { Note } from './Note.js';
|
||||
|
@ -58,7 +58,6 @@ export class Notification {
|
|||
* renote - 投稿がRenoteされた
|
||||
* quote - 投稿が引用Renoteされた
|
||||
* reaction - 投稿にリアクションされた
|
||||
* pollVote - 投稿のアンケートに投票された (廃止)
|
||||
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
|
||||
* receiveFollowRequest - フォローリクエストされた
|
||||
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
|
||||
|
@ -67,7 +66,10 @@ export class Notification {
|
|||
*/
|
||||
@Index()
|
||||
@Column('enum', {
|
||||
enum: notificationTypes,
|
||||
enum: [
|
||||
...notificationTypes,
|
||||
...obsoleteNotificationTypes,
|
||||
],
|
||||
comment: 'The type of the Notification.',
|
||||
})
|
||||
public type: typeof notificationTypes[number];
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { ffVisibility, notificationTypes } from '@/types.js';
|
||||
import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/types.js';
|
||||
import { id } from '../id.js';
|
||||
import { User } from './User.js';
|
||||
import { Page } from './Page.js';
|
||||
|
@ -205,7 +205,7 @@ export class UserProfile {
|
|||
enum: [
|
||||
...notificationTypes,
|
||||
// マイグレーションで削除が困難なので古いenumは残しておく
|
||||
'groupInvited',
|
||||
...obsoleteNotificationTypes,
|
||||
],
|
||||
array: true,
|
||||
default: [],
|
||||
|
|
|
@ -108,9 +108,9 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
const [path] = await createTemp();
|
||||
await pump(multipartData.file, fs.createWriteStream(path));
|
||||
|
||||
const fields = {} as Record<string, string | undefined>;
|
||||
const fields = {} as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(multipartData.fields)) {
|
||||
fields[k] = v.value;
|
||||
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
|
||||
}
|
||||
|
||||
const token = fields['i'];
|
||||
|
|
|
@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
|
|||
import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
|
||||
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
|
||||
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
||||
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
||||
import * as ep___announcements from './endpoints/announcements.js';
|
||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
||||
|
@ -170,6 +171,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js';
|
|||
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
|
||||
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
|
||||
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
||||
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
|
||||
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
||||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||
|
@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
|
|||
import * as ep___ping from './endpoints/ping.js';
|
||||
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
|
||||
import * as ep___promo_read from './endpoints/promo/read.js';
|
||||
import * as ep___roles_list from './endpoints/roles/list.js';
|
||||
import * as ep___roles_show from './endpoints/roles/show.js';
|
||||
import * as ep___roles_users from './endpoints/roles/users.js';
|
||||
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
|
||||
import * as ep___resetDb from './endpoints/reset-db.js';
|
||||
import * as ep___resetPassword from './endpoints/reset-password.js';
|
||||
|
@ -382,6 +387,7 @@ const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useCla
|
|||
const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default };
|
||||
const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
|
||||
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
|
||||
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
|
||||
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
|
||||
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
|
||||
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
|
||||
|
@ -486,6 +492,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___
|
|||
const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default };
|
||||
const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default };
|
||||
const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default };
|
||||
const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default };
|
||||
const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default };
|
||||
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
|
||||
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
|
||||
|
@ -592,6 +599,9 @@ const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___
|
|||
const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default };
|
||||
const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default };
|
||||
const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default };
|
||||
const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
|
||||
const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
|
||||
const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
|
||||
const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
|
||||
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
|
||||
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
|
||||
|
@ -702,6 +712,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$admin_roles_assign,
|
||||
$admin_roles_unassign,
|
||||
$admin_roles_updateDefaultPolicies,
|
||||
$admin_roles_users,
|
||||
$announcements,
|
||||
$antennas_create,
|
||||
$antennas_delete,
|
||||
|
@ -806,6 +817,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_2fa_passwordLess,
|
||||
$i_2fa_registerKey,
|
||||
$i_2fa_register,
|
||||
$i_2fa_updateKey,
|
||||
$i_2fa_removeKey,
|
||||
$i_2fa_unregister,
|
||||
$i_apps,
|
||||
|
@ -912,6 +924,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$ping,
|
||||
$pinnedUsers,
|
||||
$promo_read,
|
||||
$roles_list,
|
||||
$roles_show,
|
||||
$roles_users,
|
||||
$requestResetPassword,
|
||||
$resetDb,
|
||||
$resetPassword,
|
||||
|
@ -1016,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$admin_roles_assign,
|
||||
$admin_roles_unassign,
|
||||
$admin_roles_updateDefaultPolicies,
|
||||
$admin_roles_users,
|
||||
$announcements,
|
||||
$antennas_create,
|
||||
$antennas_delete,
|
||||
|
@ -1120,6 +1136,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_2fa_passwordLess,
|
||||
$i_2fa_registerKey,
|
||||
$i_2fa_register,
|
||||
$i_2fa_updateKey,
|
||||
$i_2fa_removeKey,
|
||||
$i_2fa_unregister,
|
||||
$i_apps,
|
||||
|
@ -1226,6 +1243,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$ping,
|
||||
$pinnedUsers,
|
||||
$promo_read,
|
||||
$roles_list,
|
||||
$roles_show,
|
||||
$roles_users,
|
||||
$requestResetPassword,
|
||||
$resetDb,
|
||||
$resetPassword,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -45,7 +45,7 @@ export class GetterService {
|
|||
throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.');
|
||||
}
|
||||
|
||||
return user;
|
||||
return user as LocalUser | RemoteUser;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
|
||||
|
@ -155,19 +155,19 @@ export class SigninApiService {
|
|||
});
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 2,
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
} else {
|
||||
if (delta === null) {
|
||||
return await fail(403, {
|
||||
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
|
||||
});
|
||||
} else {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
}
|
||||
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
|
||||
if (!same && !profile.usePasswordLessLogin) {
|
||||
|
|
|
@ -20,14 +20,14 @@ type File = {
|
|||
};
|
||||
|
||||
// TODO: paramsの型をT['params']のスキーマ定義から推論する
|
||||
type executor<T extends IEndpointMeta, Ps extends Schema> =
|
||||
type Executor<T extends IEndpointMeta, Ps extends Schema> =
|
||||
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
|
||||
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
|
||||
|
||||
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
|
||||
public exec: (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
|
||||
|
||||
constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) {
|
||||
constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
|
||||
const validate = ajv.compile(paramDef);
|
||||
|
||||
this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => {
|
||||
|
|
|
@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
|
|||
import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
|
||||
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
|
||||
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
||||
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
||||
import * as ep___announcements from './endpoints/announcements.js';
|
||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
||||
|
@ -170,6 +171,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js';
|
|||
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
|
||||
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
|
||||
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
||||
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
|
||||
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
||||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||
|
@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
|
|||
import * as ep___ping from './endpoints/ping.js';
|
||||
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
|
||||
import * as ep___promo_read from './endpoints/promo/read.js';
|
||||
import * as ep___roles_list from './endpoints/roles/list.js';
|
||||
import * as ep___roles_show from './endpoints/roles/show.js';
|
||||
import * as ep___roles_users from './endpoints/roles/users.js';
|
||||
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
|
||||
import * as ep___resetDb from './endpoints/reset-db.js';
|
||||
import * as ep___resetPassword from './endpoints/reset-password.js';
|
||||
|
@ -380,6 +385,7 @@ const eps = [
|
|||
['admin/roles/assign', ep___admin_roles_assign],
|
||||
['admin/roles/unassign', ep___admin_roles_unassign],
|
||||
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
|
||||
['admin/roles/users', ep___admin_roles_users],
|
||||
['announcements', ep___announcements],
|
||||
['antennas/create', ep___antennas_create],
|
||||
['antennas/delete', ep___antennas_delete],
|
||||
|
@ -484,6 +490,7 @@ const eps = [
|
|||
['i/2fa/password-less', ep___i_2fa_passwordLess],
|
||||
['i/2fa/register-key', ep___i_2fa_registerKey],
|
||||
['i/2fa/register', ep___i_2fa_register],
|
||||
['i/2fa/update-key', ep___i_2fa_updateKey],
|
||||
['i/2fa/remove-key', ep___i_2fa_removeKey],
|
||||
['i/2fa/unregister', ep___i_2fa_unregister],
|
||||
['i/apps', ep___i_apps],
|
||||
|
@ -590,6 +597,9 @@ const eps = [
|
|||
['ping', ep___ping],
|
||||
['pinned-users', ep___pinnedUsers],
|
||||
['promo/read', ep___promo_read],
|
||||
['roles/list', ep___roles_list],
|
||||
['roles/show', ep___roles_show],
|
||||
['roles/users', ep___roles_users],
|
||||
['request-reset-password', ep___requestResetPassword],
|
||||
['reset-db', ep___resetDb],
|
||||
['reset-password', ep___resetPassword],
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import type { DriveFilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
@ -161,6 +161,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
|
@ -178,7 +181,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
|
||||
const isModerator = await this.roleService.isModerator(me);
|
||||
const owner = file.userId ? await this.usersRepository.findOneByOrFail({
|
||||
id: file.userId,
|
||||
}) : null;
|
||||
|
||||
const iAmModerator = await this.roleService.isModerator(me);
|
||||
const ownerIsModerator = owner ? await this.roleService.isModerator(owner) : false;
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
|
@ -207,8 +215,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
name: file.name,
|
||||
md5: file.md5,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
requestIp: isModerator ? file.requestIp : null,
|
||||
requestHeaders: isModerator ? file.requestHeaders : null,
|
||||
requestIp: iAmModerator ? file.requestIp : null,
|
||||
requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
const roles = await this.rolesRepository.find({
|
||||
order: { lastUsedAt: 'DESC' },
|
||||
});
|
||||
return await this.roleEntityService.packMany(roles, me, { detail: false });
|
||||
return await this.roleEntityService.packMany(roles, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,12 +39,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
|
||||
private roleEntityService: RoleEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps) => {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
return await this.roleEntityService.pack(role);
|
||||
return await this.roleEntityService.pack(role, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin', 'role', 'users'],
|
||||
|
||||
requireCredential: false,
|
||||
requireAdmin: true,
|
||||
|
||||
errors: {
|
||||
noSuchRole: {
|
||||
message: 'No such role.',
|
||||
code: 'NO_SUCH_ROLE',
|
||||
id: '224eff5e-2488-4b18-b3e7-f50d94421648',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roleId: { type: 'string', format: 'misskey:id' },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
required: ['roleId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
|
||||
@Inject(DI.roleAssignmentsRepository)
|
||||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||
|
||||
private queryService: QueryService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({
|
||||
id: ps.roleId,
|
||||
});
|
||||
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
|
||||
.andWhere('assign.roleId = :roleId', { roleId: role.id })
|
||||
.innerJoinAndSelect('assign.user', 'user');
|
||||
|
||||
const assigns = await query
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await Promise.all(assigns.map(async assign => ({
|
||||
id: assign.id,
|
||||
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -59,12 +59,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
throw new Error('cannot show info of admin');
|
||||
}
|
||||
|
||||
if (!await this.roleService.isAdministrator(_me)) {
|
||||
return {
|
||||
isSuspended: user.isSuspended,
|
||||
};
|
||||
}
|
||||
|
||||
const signins = await this.signinsRepository.findBy({ userId: user.id });
|
||||
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
|
@ -89,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
moderationNote: profile.moderationNote,
|
||||
signins,
|
||||
policies: await this.roleService.getUserPolicies(user.id),
|
||||
roles: await this.roleEntityService.packMany(roles, me, { detail: false }),
|
||||
roles: await this.roleEntityService.packMany(roles, me),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import * as speakeasy from 'speakeasy';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -22,8 +25,14 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const token = ps.token.replace(/\s/g, '');
|
||||
|
@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
throw new Error('二段階認証の設定が開始されていません');
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorTempSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
if (delta === null) {
|
||||
throw new Error('not verified');
|
||||
}
|
||||
|
||||
|
@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
twoFactorSecret: profile.twoFactorTempSecret,
|
||||
twoFactorEnabled: true,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ export const paramDef = {
|
|||
attestationObject: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
challengeId: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||
},
|
||||
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
|
||||
} as const;
|
||||
|
|
|
@ -1,12 +1,23 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noKey: {
|
||||
message: 'No security key.',
|
||||
code: 'NO_SECURITY_KEY',
|
||||
id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.value === true) {
|
||||
// セキュリティキーがなければパスワードレスを有効にはできない
|
||||
const keyCount = await this.userSecurityKeysRepository.count({
|
||||
where: {
|
||||
userId: me.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
lastUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keyCount === 0) {
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
throw new ApiError(meta.errors.noKey);
|
||||
}
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
usePasswordLessLogin: ps.value,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
|
@ -42,25 +42,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
}
|
||||
|
||||
// Generate user's secret key
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 32,
|
||||
});
|
||||
const secret = new OTPAuth.Secret();
|
||||
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
twoFactorTempSecret: secret.base32,
|
||||
});
|
||||
|
||||
// Get the data URL of the authenticator URL
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret: secret.base32,
|
||||
encoding: 'base32',
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret,
|
||||
digits: 6,
|
||||
label: me.username,
|
||||
issuer: this.config.host,
|
||||
});
|
||||
const dataUrl = await QRCode.toDataURL(url);
|
||||
const url = totp.toString();
|
||||
const qr = await QRCode.toDataURL(url);
|
||||
|
||||
return {
|
||||
qr: dataUrl,
|
||||
qr,
|
||||
url,
|
||||
secret: secret.base32,
|
||||
label: me.username,
|
||||
|
|
|
@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
id: ps.credentialId,
|
||||
});
|
||||
|
||||
// 使われているキーがなくなったらパスワードレスログインをやめる
|
||||
const keyCount = await this.userSecurityKeysRepository.count({
|
||||
where: {
|
||||
userId: me.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
lastUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keyCount === 0) {
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
@ -38,7 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
await this.userProfilesRepository.update(me.id, {
|
||||
twoFactorSecret: null,
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noSuchKey: {
|
||||
message: 'No such key.',
|
||||
code: 'NO_SUCH_KEY',
|
||||
id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512',
|
||||
},
|
||||
|
||||
accessDenied: {
|
||||
message: 'You do not have edit privilege of the channel.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||
credentialId: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'credentialId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||
id: ps.credentialId,
|
||||
});
|
||||
|
||||
if (key == null) {
|
||||
throw new ApiError(meta.errors.noSuchKey);
|
||||
}
|
||||
|
||||
if (key.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
await this.userSecurityKeysRepository.update(key.id, {
|
||||
name: ps.name,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return {};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js';
|
||||
import { notificationTypes } from '@/types.js';
|
||||
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
|
@ -41,11 +41,12 @@ export const paramDef = {
|
|||
following: { type: 'boolean', default: false },
|
||||
unreadOnly: { type: 'boolean', default: false },
|
||||
markAsRead: { type: 'boolean', default: true },
|
||||
// 後方互換のため、廃止された通知タイプも受け付ける
|
||||
includeTypes: { type: 'array', items: {
|
||||
type: 'string', enum: notificationTypes,
|
||||
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
|
||||
} },
|
||||
excludeTypes: { type: 'array', items: {
|
||||
type: 'string', enum: notificationTypes,
|
||||
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
|
||||
} },
|
||||
},
|
||||
required: [],
|
||||
|
@ -84,6 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
@ -143,10 +148,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
query.setParameters(followingQuery.getParameters());
|
||||
}
|
||||
|
||||
if (ps.includeTypes && ps.includeTypes.length > 0) {
|
||||
query.andWhere('notification.type IN (:...includeTypes)', { includeTypes: ps.includeTypes });
|
||||
} else if (ps.excludeTypes && ps.excludeTypes.length > 0) {
|
||||
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes: ps.excludeTypes });
|
||||
if (includeTypes && includeTypes.length > 0) {
|
||||
query.andWhere('notification.type IN (:...includeTypes)', { includeTypes });
|
||||
} else if (excludeTypes && excludeTypes.length > 0) {
|
||||
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes });
|
||||
}
|
||||
|
||||
if (ps.unreadOnly) {
|
||||
|
|
|
@ -79,6 +79,12 @@ export const meta = {
|
|||
code: 'YOU_HAVE_BEEN_BLOCKED',
|
||||
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
|
||||
},
|
||||
|
||||
noSuchFile: {
|
||||
message: 'Some files are not found.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -207,6 +213,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
|
||||
.setParameters({ fileIds })
|
||||
.getMany();
|
||||
|
||||
if (files.length !== fileIds.length) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
let renote: Note | null = null;
|
||||
|
|
|
@ -28,6 +28,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
channelId: { type: 'string', nullable: true, format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -63,12 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
|
||||
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
|
||||
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||
|
||||
let notes = await query
|
||||
.orderBy('note.score', 'DESC')
|
||||
.take(ps.limit)
|
||||
.take(50)
|
||||
.getMany();
|
||||
|
||||
notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
|
37
packages/backend/src/server/api/endpoints/roles/list.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { RolesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['role'],
|
||||
|
||||
requireCredential: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
},
|
||||
required: [
|
||||
],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
|
||||
private roleEntityService: RoleEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const roles = await this.rolesRepository.findBy({
|
||||
isPublic: true,
|
||||
});
|
||||
return await this.roleEntityService.packMany(roles, me);
|
||||
});
|
||||
}
|
||||
}
|
52
packages/backend/src/server/api/endpoints/roles/show.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { RolesRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['role', 'users'],
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
errors: {
|
||||
noSuchRole: {
|
||||
message: 'No such role.',
|
||||
code: 'NO_SUCH_ROLE',
|
||||
id: 'de5502bf-009a-4639-86c1-fec349e46dcb',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roleId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roleId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
|
||||
private roleEntityService: RoleEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({
|
||||
id: ps.roleId,
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
|
||||
return await this.roleEntityService.pack(role, me);
|
||||
});
|
||||
}
|
||||
}
|
71
packages/backend/src/server/api/endpoints/roles/users.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['role', 'users'],
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
errors: {
|
||||
noSuchRole: {
|
||||
message: 'No such role.',
|
||||
code: 'NO_SUCH_ROLE',
|
||||
id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roleId: { type: 'string', format: 'misskey:id' },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
required: ['roleId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
|
||||
@Inject(DI.roleAssignmentsRepository)
|
||||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||
|
||||
private queryService: QueryService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({
|
||||
id: ps.roleId,
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
|
||||
.andWhere('assign.roleId = :roleId', { roleId: role.id })
|
||||
.innerJoinAndSelect('assign.user', 'user');
|
||||
|
||||
const assigns = await query
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await Promise.all(assigns.map(async assign => ({
|
||||
id: assign.id,
|
||||
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
@ -36,13 +37,13 @@ export const paramDef = {
|
|||
properties: {
|
||||
username: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username']
|
||||
required: ['username'],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
host: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['host']
|
||||
required: ['host'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
@ -53,6 +54,9 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
@ -62,79 +66,76 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
|
||||
|
||||
if (ps.host) {
|
||||
const q = this.usersRepository.createQueryBuilder('user')
|
||||
.where('user.isSuspended = FALSE')
|
||||
.andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' });
|
||||
|
||||
const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => {
|
||||
if (ps.username) {
|
||||
q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
|
||||
query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
|
||||
}
|
||||
|
||||
q.andWhere('user.updatedAt IS NOT NULL');
|
||||
q.orderBy('user.updatedAt', 'DESC');
|
||||
|
||||
const users = await q.take(ps.limit).getMany();
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { detail: ps.detail });
|
||||
} else if (ps.username) {
|
||||
let users: User[] = [];
|
||||
|
||||
if (me) {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
.where(`user.id IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}));
|
||||
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
|
||||
users = await query
|
||||
.orderBy('user.usernameLower', 'ASC')
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
if (users.length < ps.limit) {
|
||||
const otherQuery = await this.usersRepository.createQueryBuilder('user')
|
||||
.where(`user.id NOT IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
|
||||
.andWhere('user.updatedAt IS NOT NULL');
|
||||
|
||||
otherQuery.setParameters(followingQuery.getParameters());
|
||||
|
||||
const otherUsers = await otherQuery
|
||||
.orderBy('user.updatedAt', 'DESC')
|
||||
.take(ps.limit - users.length)
|
||||
.getMany();
|
||||
|
||||
users = users.concat(otherUsers);
|
||||
if (ps.host) {
|
||||
if (ps.host === this.config.hostname || ps.host === '.') {
|
||||
query.andWhere('user.host IS NULL');
|
||||
} else {
|
||||
query.andWhere('user.host LIKE :host', {
|
||||
host: sqlLikeEscape(ps.host.toLowerCase()) + '%',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
users = await this.usersRepository.createQueryBuilder('user')
|
||||
.where('user.isSuspended = FALSE')
|
||||
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
|
||||
.andWhere('user.updatedAt IS NOT NULL')
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
|
||||
|
||||
let users: User[] = [];
|
||||
|
||||
if (me) {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = setUsernameAndHostQuery()
|
||||
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}));
|
||||
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
|
||||
users = await query
|
||||
.orderBy('user.usernameLower', 'ASC')
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
if (users.length < ps.limit) {
|
||||
const otherQuery = setUsernameAndHostQuery()
|
||||
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.updatedAt IS NOT NULL');
|
||||
|
||||
otherQuery.setParameters(followingQuery.getParameters());
|
||||
|
||||
const otherUsers = await otherQuery
|
||||
.orderBy('user.updatedAt', 'DESC')
|
||||
.take(ps.limit - users.length)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
|
||||
users = users.concat(otherUsers);
|
||||
}
|
||||
} else {
|
||||
const query = setUsernameAndHostQuery()
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.updatedAt IS NOT NULL');
|
||||
|
||||
users = await query
|
||||
.orderBy('user.updatedAt', 'DESC')
|
||||
.take(ps.limit - users.length)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
return [];
|
||||
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,16 +9,26 @@
|
|||
{
|
||||
"src": "/static-assets/icons/192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static-assets/icons/512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static-assets/splash.png",
|
||||
"sizes": "300x300",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share/",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
|
||||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
|
||||
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
|
||||
|
||||
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
||||
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
import * as assert from 'assert';
|
||||
import * as mfm from 'mfm-js';
|
||||
|
||||
import { toHtml } from '../../src/mfm/to-html.js';
|
||||
import { fromHtml } from '../../src/mfm/from-html.js';
|
||||
|
||||
describe('toHtml', () => {
|
||||
test('br', () => {
|
||||
const input = 'foo\nbar\nbaz';
|
||||
const output = '<p><span>foo<br>bar<br>baz</span></p>';
|
||||
assert.equal(toHtml(mfm.parse(input)), output);
|
||||
});
|
||||
|
||||
test('br alt', () => {
|
||||
const input = 'foo\r\nbar\rbaz';
|
||||
const output = '<p><span>foo<br>bar<br>baz</span></p>';
|
||||
assert.equal(toHtml(mfm.parse(input)), output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromHtml', () => {
|
||||
test('p', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a</p><p>b</p>'), 'a\n\nb');
|
||||
});
|
||||
|
||||
test('block element', () => {
|
||||
assert.deepStrictEqual(fromHtml('<div>a</div><div>b</div>'), 'a\nb');
|
||||
});
|
||||
|
||||
test('inline element', () => {
|
||||
assert.deepStrictEqual(fromHtml('<ul><li>a</li><li>b</li></ul>'), 'a\nb');
|
||||
});
|
||||
|
||||
test('block code', () => {
|
||||
assert.deepStrictEqual(fromHtml('<pre><code>a\nb</code></pre>'), '```\na\nb\n```');
|
||||
});
|
||||
|
||||
test('inline code', () => {
|
||||
assert.deepStrictEqual(fromHtml('<code>a</code>'), '`a`');
|
||||
});
|
||||
|
||||
test('quote', () => {
|
||||
assert.deepStrictEqual(fromHtml('<blockquote>a\nb</blockquote>'), '> a\n> b');
|
||||
});
|
||||
|
||||
test('br', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>abc<br><br/>d</p>'), 'abc\n\nd');
|
||||
});
|
||||
|
||||
test('link with different text', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/b">c</a> d</p>'), 'a [c](https://example.com/b) d');
|
||||
});
|
||||
|
||||
test('link with different text, but not encoded', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/ä">c</a> d</p>'), 'a [c](<https://example.com/ä>) d');
|
||||
});
|
||||
|
||||
test('link with same text', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/b">https://example.com/b</a> d</p>'), 'a https://example.com/b d');
|
||||
});
|
||||
|
||||
test('link with same text, but not encoded', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/ä">https://example.com/ä</a> d</p>'), 'a <https://example.com/ä> d');
|
||||
});
|
||||
|
||||
test('link with no url', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="b">c</a> d</p>'), 'a [c](b) d');
|
||||
});
|
||||
|
||||
test('link without href', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a>c</a> d</p>'), 'a c d');
|
||||
});
|
||||
|
||||
test('link without text', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/b"></a> d</p>'), 'a https://example.com/b d');
|
||||
});
|
||||
|
||||
test('link without both', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a></a> d</p>'), 'a d');
|
||||
});
|
||||
|
||||
test('mention', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/@user" class="u-url mention">@user</a> d</p>'), 'a @user@example.com d');
|
||||
});
|
||||
|
||||
test('hashtag', () => {
|
||||
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/tags/a">#a</a> d</p>', ['#a']), 'a #a d');
|
||||
});
|
||||
});
|
|
@ -1,83 +0,0 @@
|
|||
/*
|
||||
import * as assert from 'assert';
|
||||
|
||||
import { toDbReaction } from '../src/misc/reaction-lib.js';
|
||||
|
||||
describe('toDbReaction', async () => {
|
||||
test('既存の文字列リアクションはそのまま', async () => {
|
||||
assert.strictEqual(await toDbReaction('like'), 'like');
|
||||
});
|
||||
|
||||
test('Unicodeプリンは寿司化不能とするため文字列化しない', async () => {
|
||||
assert.strictEqual(await toDbReaction('🍮'), '🍮');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する like', async () => {
|
||||
assert.strictEqual(await toDbReaction('👍'), 'like');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する love', async () => {
|
||||
assert.strictEqual(await toDbReaction('❤️'), 'love');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => {
|
||||
assert.strictEqual(await toDbReaction('❤'), 'love');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する laugh', async () => {
|
||||
assert.strictEqual(await toDbReaction('😆'), 'laugh');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する hmm', async () => {
|
||||
assert.strictEqual(await toDbReaction('🤔'), 'hmm');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する surprise', async () => {
|
||||
assert.strictEqual(await toDbReaction('😮'), 'surprise');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する congrats', async () => {
|
||||
assert.strictEqual(await toDbReaction('🎉'), 'congrats');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する angry', async () => {
|
||||
assert.strictEqual(await toDbReaction('💢'), 'angry');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する confused', async () => {
|
||||
assert.strictEqual(await toDbReaction('😥'), 'confused');
|
||||
});
|
||||
|
||||
test('プリン以外の既存のリアクションは文字列化する rip', async () => {
|
||||
assert.strictEqual(await toDbReaction('😇'), 'rip');
|
||||
});
|
||||
|
||||
test('それ以外はUnicodeのまま', async () => {
|
||||
assert.strictEqual(await toDbReaction('🍅'), '🍅');
|
||||
});
|
||||
|
||||
test('異体字セレクタ除去', async () => {
|
||||
assert.strictEqual(await toDbReaction('㊗️'), '㊗');
|
||||
});
|
||||
|
||||
test('異体字セレクタ除去 必要なし', async () => {
|
||||
assert.strictEqual(await toDbReaction('㊗'), '㊗');
|
||||
});
|
||||
|
||||
test('fallback - undefined', async () => {
|
||||
assert.strictEqual(await toDbReaction(undefined), 'like');
|
||||
});
|
||||
|
||||
test('fallback - null', async () => {
|
||||
assert.strictEqual(await toDbReaction(null), 'like');
|
||||
});
|
||||
|
||||
test('fallback - empty', async () => {
|
||||
assert.strictEqual(await toDbReaction(''), 'like');
|
||||
});
|
||||
|
||||
test('fallback - unknown', async () => {
|
||||
assert.strictEqual(await toDbReaction('unknown'), 'like');
|
||||
});
|
||||
});
|
||||
*/
|
|
@ -37,6 +37,7 @@
|
|||
},
|
||||
"compileOnSave": false,
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
"./**/*.ts",
|
||||
"../src/@types/**/*.ts",
|
||||
]
|
||||
}
|
||||
|
|
102
packages/backend/test/unit/MfmService.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import * as assert from 'assert';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { MfmService } from '@/core/MfmService.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
|
||||
describe('MfmService', () => {
|
||||
let mfmService: MfmService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = await Test.createTestingModule({
|
||||
imports: [GlobalModule, CoreModule],
|
||||
}).compile();
|
||||
mfmService = app.get<MfmService>(MfmService);
|
||||
});
|
||||
|
||||
describe('toHtml', () => {
|
||||
test('br', () => {
|
||||
const input = 'foo\nbar\nbaz';
|
||||
const output = '<p><span>foo<br>bar<br>baz</span></p>';
|
||||
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
|
||||
});
|
||||
|
||||
test('br alt', () => {
|
||||
const input = 'foo\r\nbar\rbaz';
|
||||
const output = '<p><span>foo<br>bar<br>baz</span></p>';
|
||||
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromHtml', () => {
|
||||
test('p', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a</p><p>b</p>'), 'a\n\nb');
|
||||
});
|
||||
|
||||
test('block element', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<div>a</div><div>b</div>'), 'a\nb');
|
||||
});
|
||||
|
||||
test('inline element', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<ul><li>a</li><li>b</li></ul>'), 'a\nb');
|
||||
});
|
||||
|
||||
test('block code', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<pre><code>a\nb</code></pre>'), '```\na\nb\n```');
|
||||
});
|
||||
|
||||
test('inline code', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<code>a</code>'), '`a`');
|
||||
});
|
||||
|
||||
test('quote', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<blockquote>a\nb</blockquote>'), '> a\n> b');
|
||||
});
|
||||
|
||||
test('br', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>abc<br><br/>d</p>'), 'abc\n\nd');
|
||||
});
|
||||
|
||||
test('link with different text', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/b">c</a> d</p>'), 'a [c](https://example.com/b) d');
|
||||
});
|
||||
|
||||
test('link with different text, but not encoded', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/ä">c</a> d</p>'), 'a [c](<https://example.com/ä>) d');
|
||||
});
|
||||
|
||||
test('link with same text', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/b">https://example.com/b</a> d</p>'), 'a https://example.com/b d');
|
||||
});
|
||||
|
||||
test('link with same text, but not encoded', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/ä">https://example.com/ä</a> d</p>'), 'a <https://example.com/ä> d');
|
||||
});
|
||||
|
||||
test('link with no url', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="b">c</a> d</p>'), 'a [c](b) d');
|
||||
});
|
||||
|
||||
test('link without href', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a>c</a> d</p>'), 'a c d');
|
||||
});
|
||||
|
||||
test('link without text', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/b"></a> d</p>'), 'a https://example.com/b d');
|
||||
});
|
||||
|
||||
test('link without both', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a></a> d</p>'), 'a d');
|
||||
});
|
||||
|
||||
test('mention', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/@user" class="u-url mention">@user</a> d</p>'), 'a @user@example.com d');
|
||||
});
|
||||
|
||||
test('hashtag', () => {
|
||||
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/tags/a">#a</a> d</p>', ['#a']), 'a #a d');
|
||||
});
|
||||
});
|
||||
});
|
92
packages/backend/test/unit/ReactionService.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import * as assert from 'assert';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
|
||||
describe('ReactionService', () => {
|
||||
let reactionService: ReactionService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = await Test.createTestingModule({
|
||||
imports: [GlobalModule, CoreModule],
|
||||
}).compile();
|
||||
reactionService = app.get<ReactionService>(ReactionService);
|
||||
});
|
||||
|
||||
describe('toDbReaction', () => {
|
||||
test('絵文字リアクションはそのまま', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('👍'), '👍');
|
||||
assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する pudding', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する like', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('like'), '👍');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する love', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('love'), '❤');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する laugh', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する hmm', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する surprise', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する congrats', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する angry', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('angry'), '💢');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する confused', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('confused'), '😥');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する rip', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('rip'), '😇');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する star', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('star'), '⭐');
|
||||
});
|
||||
|
||||
test('異体字セレクタ除去', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗');
|
||||
});
|
||||
|
||||
test('異体字セレクタ除去 必要なし', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗');
|
||||
});
|
||||
|
||||
test('fallback - undefined', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(undefined), '👍');
|
||||
});
|
||||
|
||||
test('fallback - null', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(null), '👍');
|
||||
});
|
||||
|
||||
test('fallback - empty', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(''), '👍');
|
||||
});
|
||||
|
||||
test('fallback - unknown', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,7 +1,8 @@
|
|||
import * as assert from 'assert';
|
||||
import httpSignature from '@peertube/http-signature';
|
||||
import { genRsaKeyPair } from '../../src/misc/gen-key-pair.js';
|
||||
import { createSignedPost, createSignedGet } from '../../src/activitypub/ap-request.js';
|
||||
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||
|
||||
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
|
||||
return {
|
||||
|
@ -9,7 +10,7 @@ export const buildParsedSignature = (signingString: string, signature: string, a
|
|||
params: {
|
||||
keyId: 'KeyID', // dummy, not used for verify
|
||||
algorithm: algorithm,
|
||||
headers: [ '(request-target)', 'date', 'host', 'digest' ], // dummy, not used for verify
|
||||
headers: ['(request-target)', 'date', 'host', 'digest'], // dummy, not used for verify
|
||||
signature: signature,
|
||||
},
|
||||
signingString: signingString,
|
||||
|
@ -29,7 +30,7 @@ describe('ap-request', () => {
|
|||
'User-Agent': 'UA',
|
||||
};
|
||||
|
||||
const req = createSignedPost({ key, url, body, additionalHeaders: headers });
|
||||
const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers });
|
||||
|
||||
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256');
|
||||
|
||||
|
@ -45,7 +46,7 @@ describe('ap-request', () => {
|
|||
'User-Agent': 'UA',
|
||||
};
|
||||
|
||||
const req = createSignedGet({ key, url, additionalHeaders: headers });
|
||||
const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers });
|
||||
|
||||
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256');
|
||||
|
|
@ -19,7 +19,7 @@ import Logger from '@/logger.js';
|
|||
describe('Chart', () => {
|
||||
const config = loadConfig();
|
||||
const appLockService = {
|
||||
getChartInsertLock: jest.fn().mockImplementation(() => Promise.resolve(() => {})),
|
||||
getChartInsertLock: () => () => Promise.resolve(() => {}),
|
||||
} as unknown as jest.Mocked<AppLockService>;
|
||||
|
||||
let db: DataSource | undefined;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import * as assert from 'assert';
|
||||
|
||||
import { parse } from 'mfm-js';
|
||||
import { extractMentions } from '../../src/misc/extract-mentions.js';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
|
||||
describe('Extract mentions', () => {
|
||||
test('simple', () => {
|
||||
const ast = parse('@foo @bar @baz')!;
|
||||
const ast = parse('@foo @bar @baz');
|
||||
const mentions = extractMentions(ast);
|
||||
assert.deepStrictEqual(mentions, [{
|
||||
username: 'foo',
|
||||
|
@ -23,7 +23,7 @@ describe('Extract mentions', () => {
|
|||
});
|
||||
|
||||
test('nested', () => {
|
||||
const ast = parse('@foo **@bar** @baz')!;
|
||||
const ast = parse('@foo **@bar** @baz');
|
||||
const mentions = extractMentions(ast);
|
||||
assert.deepStrictEqual(mentions, [{
|
||||
username: 'foo',
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"noEmitOnError": false,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedParameters": false,
|
||||
|
|
|
@ -55,6 +55,7 @@ module.exports = {
|
|||
'vue/multi-word-component-names': 'warn',
|
||||
'vue/require-v-for-key': 'warn',
|
||||
'vue/no-unused-components': 'warn',
|
||||
'vue/no-unused-vars': 'warn',
|
||||
'vue/valid-v-for': 'warn',
|
||||
'vue/return-in-computed-property': 'warn',
|
||||
'vue/no-setup-props-destructure': 'warn',
|
||||
|
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
|
@ -19,11 +19,11 @@
|
|||
"@vue/compiler-sfc": "3.2.47",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"autosize": "5.0.2",
|
||||
"blurhash": "2.0.4",
|
||||
"blurhash": "2.0.5",
|
||||
"broadcast-channel": "4.20.2",
|
||||
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"canvas-confetti": "1.6.0",
|
||||
"chart.js": "4.2.0",
|
||||
"chart.js": "4.2.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
|
@ -38,7 +38,7 @@
|
|||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.18.0",
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"misskey-js": "0.0.15",
|
||||
"photoswipe": "5.3.5",
|
||||
|
@ -46,13 +46,12 @@
|
|||
"punycode": "2.3.0",
|
||||
"querystring": "0.2.1",
|
||||
"rndstr": "1.0.0",
|
||||
"rollup": "3.14.0",
|
||||
"rollup": "3.17.2",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.9.0",
|
||||
"sass": "1.58.0",
|
||||
"sanitize-html": "2.10.0",
|
||||
"sass": "1.58.3",
|
||||
"seedrandom": "3.0.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.149.0",
|
||||
|
@ -64,9 +63,9 @@
|
|||
"typescript": "4.9.5",
|
||||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vue-plyr": "7.0.0",
|
||||
"vite": "4.1.1",
|
||||
"vite": "4.1.2",
|
||||
"vue": "3.2.47",
|
||||
"vue-plyr": "7.0.0",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
|
@ -76,7 +75,7 @@
|
|||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@types/matter-js": "0.18.2",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/node": "18.14.0",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.8.0",
|
||||
"@types/seedrandom": "3.0.4",
|
||||
|
@ -85,16 +84,16 @@
|
|||
"@types/uuid": "9.0.0",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.51.0",
|
||||
"@typescript-eslint/parser": "5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.52.0",
|
||||
"@typescript-eslint/parser": "5.52.0",
|
||||
"@vue/runtime-core": "3.2.47",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.5.1",
|
||||
"eslint": "8.33.0",
|
||||
"cypress": "12.6.0",
|
||||
"eslint": "8.34.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-vue": "9.9.0",
|
||||
"start-server-and-test": "1.15.3",
|
||||
"start-server-and-test": "1.15.4",
|
||||
"vue-eslint-parser": "9.1.0",
|
||||
"vue-tsc": "1.0.24"
|
||||
"vue-tsc": "1.1.4"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -43,7 +43,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const uiWindow = shallowRef<InstanceType<typeof MkWindow>>();
|
||||
const comment = ref(props.initialComment || '');
|
||||
const comment = ref(props.initialComment ?? '');
|
||||
|
||||
function send() {
|
||||
os.apiWithDialog('users/report-abuse', {
|
||||
|
|
|
@ -209,7 +209,7 @@ function exec() {
|
|||
}
|
||||
} else if (props.type === 'hashtag') {
|
||||
if (!props.q || props.q === '') {
|
||||
hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') || '[]');
|
||||
hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]');
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const cacheKey = `autocomplete:hashtag:${props.q}`;
|
||||
|
|
|
@ -69,7 +69,7 @@ const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown
|
|||
if (loaded) {
|
||||
available.value = true;
|
||||
} else {
|
||||
(document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), {
|
||||
(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
|
||||
async: true,
|
||||
id: scriptId.value,
|
||||
src: src.value,
|
||||
|
|
|
@ -22,9 +22,6 @@ import * as game from '@/scripts/clicker-game';
|
|||
import number from '@/filters/number';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
|
||||
defineProps<{
|
||||
}>();
|
||||
|
||||
const saveData = game.saveData;
|
||||
const cookies = computed(() => saveData.value?.cookies);
|
||||
let cps = $ref(0);
|
||||
|
|
|
@ -32,6 +32,8 @@ let rootEl = $shallowRef<HTMLDivElement>();
|
|||
|
||||
let zIndex = $ref<number>(os.claimZIndex('high'));
|
||||
|
||||
const SCROLLBAR_THICKNESS = 16;
|
||||
|
||||
onMounted(() => {
|
||||
let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
|
@ -39,12 +41,12 @@ onMounted(() => {
|
|||
const width = rootEl.offsetWidth;
|
||||
const height = rootEl.offsetHeight;
|
||||
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset;
|
||||
if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
|
||||
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset;
|
||||
if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
|
||||
top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { length } from 'stringz';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { concat } from '@/scripts/array';
|
||||
import { i18n } from '@/i18n';
|
||||
|
@ -23,7 +22,7 @@ const emit = defineEmits<{
|
|||
|
||||
const label = computed(() => {
|
||||
return concat([
|
||||
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
|
||||
props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [],
|
||||
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
|
||||
props.note.poll != null ? [i18n.ts.poll] : [],
|
||||
] as string[][]).join(' / ');
|
||||
|
|
|
@ -14,8 +14,12 @@
|
|||
</div>
|
||||
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
|
||||
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption>
|
||||
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" />
|
||||
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" />
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
|
@ -28,7 +32,7 @@
|
|||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
<MkButton v-if="showOkButton" inline primary :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 @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
<div v-if="actions" :class="$style.buttons">
|
||||
|
@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue';
|
|||
import { i18n } from '@/i18n';
|
||||
|
||||
type Input = {
|
||||
type: HTMLInputElement['type'];
|
||||
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||
placeholder?: string | null;
|
||||
default: any | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
|
@ -98,8 +105,28 @@ const emit = defineEmits<{
|
|||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref(props.input?.default || null);
|
||||
const selectedValue = ref(props.select?.default || null);
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? null);
|
||||
const selectedValue = ref(props.select?.default ?? null);
|
||||
|
||||
let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null);
|
||||
const okButtonDisabled = $computed<boolean>(() => {
|
||||
if (props.input) {
|
||||
if (props.input.minLength) {
|
||||
if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) {
|
||||
disabledReason = 'charactersBelow';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (props.input.maxLength) {
|
||||
if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) {
|
||||
disabledReason = 'charactersExceeded';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
emit('done', { canceled, result });
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
<template>
|
||||
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
|
||||
<div :class="$style.header" class="_button" @click="toggle">
|
||||
<span :class="$style.headerIcon"><slot name="icon"></slot></span>
|
||||
<span :class="$style.headerText"><slot name="label"></slot></span>
|
||||
<span :class="$style.headerRight">
|
||||
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||
<div :class="$style.headerText">
|
||||
<div :class="$style.headerTextMain">
|
||||
<slot name="label"></slot>
|
||||
</div>
|
||||
<div :class="$style.headerTextSub">
|
||||
<slot name="caption"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
|
||||
<Transition
|
||||
|
@ -139,6 +146,17 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
.headerUpper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headerLower {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
margin-right: 0.75em;
|
||||
flex-shrink: 0;
|
||||
|
@ -161,6 +179,15 @@ onMounted(() => {
|
|||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.headerTextMain {
|
||||
|
||||
}
|
||||
|
||||
.headerTextSub {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
@input="onInput"
|
||||
>
|
||||
<datalist v-if="datalist" :id="id">
|
||||
<option v-for="data in datalist" :value="data"/>
|
||||
<option v-for="data in datalist" :key="data" :value="data"/>
|
||||
</datalist>
|
||||
<div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
|
||||
</div>
|
||||
|
@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval';
|
|||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number;
|
||||
modelValue: string | number | null;
|
||||
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
|
@ -49,7 +49,7 @@ const props = defineProps<{
|
|||
pattern?: string;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
autocomplete?: boolean;
|
||||
autocomplete?: string;
|
||||
spellcheck?: boolean;
|
||||
step?: any;
|
||||
datalist?: string[];
|
||||
|
|
|
@ -45,8 +45,8 @@ onMounted(() => {
|
|||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
alt: media.comment || media.name,
|
||||
comment: media.comment || media.name,
|
||||
alt: media.comment ?? media.name,
|
||||
comment: media.comment ?? media.name,
|
||||
};
|
||||
if (media.properties.orientation != null && media.properties.orientation >= 5) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
|
@ -90,8 +90,8 @@ onMounted(() => {
|
|||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl;
|
||||
itemData.alt = file.comment || file.name;
|
||||
itemData.comment = file.comment || file.name;
|
||||
itemData.alt = file.comment ?? file.name;
|
||||
itemData.comment = file.comment ?? file.name;
|
||||
itemData.thumbCropped = true;
|
||||
});
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<div ref="el" class="sfhdhdhr">
|
||||
<MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
|
||||
<div ref="el" :class="$style.root">
|
||||
<MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, shallowRef, watch } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from '@/types/menu';
|
||||
|
||||
|
@ -25,11 +25,21 @@ const emit = defineEmits<{
|
|||
const el = shallowRef<HTMLElement>();
|
||||
const align = 'left';
|
||||
|
||||
const SCROLLBAR_THICKNESS = 16;
|
||||
|
||||
function setPosition() {
|
||||
const rootRect = props.rootElement.getBoundingClientRect();
|
||||
const rect = props.targetElement.getBoundingClientRect();
|
||||
const left = props.targetElement.offsetWidth;
|
||||
const top = (rect.top - rootRect.top) - 8;
|
||||
const parentRect = props.targetElement.getBoundingClientRect();
|
||||
const myRect = el.value.getBoundingClientRect();
|
||||
|
||||
let left = props.targetElement.offsetWidth;
|
||||
let top = (parentRect.top - rootRect.top) - 8;
|
||||
if (rootRect.left + left + myRect.width >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
|
||||
left = -myRect.width;
|
||||
}
|
||||
if (rootRect.top + top + myRect.height >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
|
||||
top = top - ((rootRect.top + top + myRect.height) - (window.innerHeight - SCROLLBAR_THICKNESS));
|
||||
}
|
||||
el.value.style.left = left + 'px';
|
||||
el.value.style.top = top + 'px';
|
||||
}
|
||||
|
@ -46,13 +56,22 @@ watch(() => props.targetElement, () => {
|
|||
setPosition();
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver((entries, observer) => {
|
||||
setPosition();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
ro.observe(el.value);
|
||||
setPosition();
|
||||
nextTick(() => {
|
||||
setPosition();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ro.disconnect();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
checkHit: (ev: MouseEvent) => {
|
||||
return (ev.target === el.value || el.value.contains(ev.target));
|
||||
|
@ -60,8 +79,8 @@ defineExpose({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sfhdhdhr {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
|
||||
|
@ -111,11 +111,11 @@ watch(() => props.items, () => {
|
|||
immediate: true,
|
||||
});
|
||||
|
||||
let childMenu = $ref<MenuItem[] | null>();
|
||||
let childMenu = ref<MenuItem[] | null>();
|
||||
let childTarget = $shallowRef<HTMLElement | null>();
|
||||
|
||||
function closeChild() {
|
||||
childMenu = null;
|
||||
childMenu.value = null;
|
||||
childShowingItem = null;
|
||||
}
|
||||
|
||||
|
@ -140,13 +140,31 @@ function onItemMouseLeave(item) {
|
|||
if (childCloseTimer) window.clearTimeout(childCloseTimer);
|
||||
}
|
||||
|
||||
let childrenCache = new WeakMap();
|
||||
async function showChildren(item: MenuItem, ev: MouseEvent) {
|
||||
const children = ref([]);
|
||||
if (childrenCache.has(item)) {
|
||||
children.value = childrenCache.get(item);
|
||||
} else {
|
||||
if (typeof item.children === 'function') {
|
||||
children.value = [{
|
||||
type: 'pending',
|
||||
}];
|
||||
item.children().then(x => {
|
||||
children.value = x;
|
||||
childrenCache.set(item, x);
|
||||
});
|
||||
} else {
|
||||
children.value = item.children;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.asDrawer) {
|
||||
os.popupMenu(item.children, ev.currentTarget ?? ev.target);
|
||||
os.popupMenu(children, ev.currentTarget ?? ev.target);
|
||||
close();
|
||||
} else {
|
||||
childTarget = ev.currentTarget ?? ev.target;
|
||||
childMenu = item.children;
|
||||
childMenu = children;
|
||||
childShowingItem = item;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -125,7 +125,7 @@ function onBgClick() {
|
|||
}
|
||||
|
||||
if (type === 'drawer') {
|
||||
maxHeight = window.innerHeight / 1.5;
|
||||
maxHeight = (window.innerHeight - SCROLLBAR_THICKNESS) / 1.5;
|
||||
}
|
||||
|
||||
const keymap = {
|
||||
|
@ -133,6 +133,7 @@ const keymap = {
|
|||
};
|
||||
|
||||
const MARGIN = 16;
|
||||
const SCROLLBAR_THICKNESS = 16;
|
||||
|
||||
const align = () => {
|
||||
if (props.src == null) return;
|
||||
|
@ -170,15 +171,15 @@ const align = () => {
|
|||
|
||||
if (fixed) {
|
||||
// 画面から横にはみ出る場合
|
||||
if (left + width > window.innerWidth) {
|
||||
left = window.innerWidth - width;
|
||||
if (left + width > (window.innerWidth - SCROLLBAR_THICKNESS)) {
|
||||
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width;
|
||||
}
|
||||
|
||||
const underSpace = (window.innerHeight - MARGIN) - top;
|
||||
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - top;
|
||||
const upperSpace = (srcRect.top - MARGIN);
|
||||
|
||||
// 画面から縦にはみ出る場合
|
||||
if (top + height > (window.innerHeight - MARGIN)) {
|
||||
if (top + height > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
|
||||
if (props.noOverlap && props.anchor.x === 'center') {
|
||||
if (underSpace >= (upperSpace / 3)) {
|
||||
maxHeight = underSpace;
|
||||
|
@ -187,22 +188,22 @@ const align = () => {
|
|||
top = (upperSpace + MARGIN) - height;
|
||||
}
|
||||
} else {
|
||||
top = (window.innerHeight - MARGIN) - height;
|
||||
top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height;
|
||||
}
|
||||
} else {
|
||||
maxHeight = underSpace;
|
||||
}
|
||||
} else {
|
||||
// 画面から横にはみ出る場合
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset - 1;
|
||||
if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) {
|
||||
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1;
|
||||
}
|
||||
|
||||
const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset);
|
||||
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset);
|
||||
const upperSpace = (srcRect.top - MARGIN);
|
||||
|
||||
// 画面から縦にはみ出る場合
|
||||
if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) {
|
||||
if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
|
||||
if (props.noOverlap && props.anchor.x === 'center') {
|
||||
if (underSpace >= (upperSpace / 3)) {
|
||||
maxHeight = underSpace;
|
||||
|
@ -211,7 +212,7 @@ const align = () => {
|
|||
top = window.pageYOffset + ((upperSpace + MARGIN) - height);
|
||||
}
|
||||
} else {
|
||||
top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1;
|
||||
top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1;
|
||||
}
|
||||
} else {
|
||||
maxHeight = underSpace;
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
|
||||
|
@ -155,7 +155,6 @@ import { deepClone } from '@/scripts/clone';
|
|||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
import { getNoteSummary } from '@/scripts/get-note-summary';
|
||||
import { shownNoteIds } from '@/os';
|
||||
import { MenuItem } from '@/types/menu';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -195,6 +194,8 @@ const isMyRenote = $i && ($i.id === note.userId);
|
|||
const showContent = ref(false);
|
||||
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
|
||||
const isLong = (appearNote.cw == null && appearNote.text != null && (
|
||||
(appearNote.text.includes('$[x3')) ||
|
||||
(appearNote.text.includes('$[x4')) ||
|
||||
(appearNote.text.split('\n').length > 9) ||
|
||||
(appearNote.text.length > 500) ||
|
||||
(appearNote.files.length >= 5) ||
|
||||
|
@ -207,9 +208,7 @@ const translation = ref<any>(null);
|
|||
const translating = ref(false);
|
||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
|
||||
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id)));
|
||||
|
||||
shownNoteIds.add(appearNote.id);
|
||||
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || (appearNote.myReaction != null)));
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
|
@ -256,7 +255,7 @@ function renote(viaKeyboard = false) {
|
|||
text: i18n.ts.inChannelRenote,
|
||||
icon: 'ti ti-repeat',
|
||||
action: () => {
|
||||
os.api('notes/create', {
|
||||
os.apiWithDialog('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
channelId: appearNote.channelId,
|
||||
});
|
||||
|
@ -277,7 +276,7 @@ function renote(viaKeyboard = false) {
|
|||
text: i18n.ts.renote,
|
||||
icon: 'ti ti-repeat',
|
||||
action: () => {
|
||||
os.api('notes/create', {
|
||||
os.apiWithDialog('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
});
|
||||
},
|
||||
|
@ -674,9 +673,17 @@ function showReactions(): void {
|
|||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
@container (max-width: 580px) {
|
||||
.root {
|
||||
font-size: 0.9em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.renote {
|
||||
padding: 12px 26px 0 26px;
|
||||
}
|
||||
|
||||
.article {
|
||||
padding: 24px 26px 14px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
@ -685,7 +692,21 @@ function showReactions(): void {
|
|||
}
|
||||
}
|
||||
|
||||
@container (max-width: 450px) {
|
||||
@container (max-width: 500px) {
|
||||
.root {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.renote {
|
||||
padding: 10px 22px 0 22px;
|
||||
}
|
||||
|
||||
.article {
|
||||
padding: 20px 22px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
.renote {
|
||||
padding: 8px 16px 0 16px;
|
||||
}
|
||||
|
@ -702,7 +723,9 @@ function showReactions(): void {
|
|||
.article {
|
||||
padding: 14px 16px 9px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 450px) {
|
||||
.avatar {
|
||||
margin: 0 10px 8px 0;
|
||||
width: 46px;
|
||||
|
@ -711,7 +734,7 @@ function showReactions(): void {
|
|||
}
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
@container (max-width: 400px) {
|
||||
.footerButton {
|
||||
&:not(:last-child) {
|
||||
margin-right: 18px;
|
||||
|
@ -719,6 +742,14 @@ function showReactions(): void {
|
|||
}
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
.footerButton {
|
||||
&:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.avatar {
|
||||
width: 44px;
|
||||
|
@ -727,7 +758,7 @@ function showReactions(): void {
|
|||
|
||||
.footerButton {
|
||||
&:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<article class="article" @contextmenu.stop="onContextmenu">
|
||||
|
@ -48,7 +48,7 @@
|
|||
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username"><MkAcct :user="appearNote.user"/></div>
|
||||
|
@ -250,7 +250,7 @@ function renote(viaKeyboard = false) {
|
|||
text: i18n.ts.inChannelRenote,
|
||||
icon: 'ti ti-repeat',
|
||||
action: () => {
|
||||
os.api('notes/create', {
|
||||
os.apiWithDialog('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
channelId: appearNote.channelId,
|
||||
});
|
||||
|
@ -271,7 +271,7 @@ function renote(viaKeyboard = false) {
|
|||
text: i18n.ts.renote,
|
||||
icon: 'ti ti-repeat',
|
||||
action: () => {
|
||||
os.api('notes/create', {
|
||||
os.apiWithDialog('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
:with-ok-button="true"
|
||||
:ok-button-disabled="false"
|
||||
@ok="ok()"
|
||||
@close="dialog.close()"
|
||||
@close="dialog?.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.notificationSetting }}</template>
|
||||
|
@ -25,7 +25,7 @@
|
|||
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
|
||||
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
|
||||
</div>
|
||||
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
|
||||
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
|
||||
</template>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
|
@ -33,14 +33,16 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { notificationTypes } from 'misskey-js';
|
||||
import { ref, Ref } from 'vue';
|
||||
import MkSwitch from './MkSwitch.vue';
|
||||
import MkInfo from './MkInfo.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { notificationTypes } from '@/const';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { includingTypes: string[] | null }): void,
|
||||
(ev: 'closed'): void,
|
||||
|
@ -54,39 +56,35 @@ const props = withDefaults(defineProps<{
|
|||
showGlobalToggle: true,
|
||||
});
|
||||
|
||||
let includingTypes = $computed(() => props.includingTypes || []);
|
||||
let includingTypes = $computed(() => props.includingTypes?.filter(x => notificationTypes.includes(x)) ?? []);
|
||||
|
||||
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({});
|
||||
const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(includingTypes.includes(t)) }), {} as any);
|
||||
let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle);
|
||||
|
||||
for (const ntype of notificationTypes) {
|
||||
typesMap[ntype] = includingTypes.includes(ntype);
|
||||
}
|
||||
|
||||
function ok() {
|
||||
if (useGlobalSetting) {
|
||||
emit('done', { includingTypes: null });
|
||||
} else {
|
||||
emit('done', {
|
||||
includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][])
|
||||
.filter(type => typesMap[type]),
|
||||
.filter(type => typesMap[type].value),
|
||||
});
|
||||
}
|
||||
|
||||
dialog.close();
|
||||
if (dialog) dialog.close();
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
for (const type in typesMap) {
|
||||
typesMap[type as typeof notificationTypes[number]] = false;
|
||||
for (const type of notificationTypes) {
|
||||
typesMap[type].value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function enableAll() {
|
||||
for (const type in typesMap) {
|
||||
typesMap[type as typeof notificationTypes[number]] = true;
|
||||
for (const type of notificationTypes) {
|
||||
typesMap[type].value = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, onMounted, computed, shallowRef } from 'vue';
|
||||
import { notificationTypes } from 'misskey-js';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import XNotification from '@/components/MkNotification.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
|
@ -26,6 +25,7 @@ import XNote from '@/components/MkNote.vue';
|
|||
import { stream } from '@/stream';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { notificationTypes } from '@/const';
|
||||
|
||||
const props = defineProps<{
|
||||
includeTypes?: typeof notificationTypes[number][];
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
</template>
|
||||
|
||||
<div :class="$style.root" :style="{ background: pageMetadata?.value?.bg }" style="container-type: inline-size;">
|
||||
<RouterView :router="router"/>
|
||||
<RouterView :key="reloadCount" :router="router"/>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
@ -67,6 +67,10 @@ const buttonsLeft = $computed(() => {
|
|||
});
|
||||
const buttonsRight = $computed(() => {
|
||||
const buttons = [{
|
||||
icon: 'ti ti-reload',
|
||||
title: i18n.ts.reload,
|
||||
onClick: reload,
|
||||
}, {
|
||||
icon: 'ti ti-player-eject',
|
||||
title: i18n.ts.showInPage,
|
||||
onClick: expand,
|
||||
|
@ -74,6 +78,7 @@ const buttonsRight = $computed(() => {
|
|||
|
||||
return buttons;
|
||||
});
|
||||
let reloadCount = $ref(0);
|
||||
|
||||
router.addListener('push', ctx => {
|
||||
history.push({ path: ctx.path, key: ctx.key });
|
||||
|
@ -115,6 +120,10 @@ function back() {
|
|||
router.replace(history[history.length - 1].path, history[history.length - 1].key);
|
||||
}
|
||||
|
||||
function reload() {
|
||||
reloadCount++;
|
||||
}
|
||||
|
||||
function close() {
|
||||
windowEl.close();
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, o
|
|||
import * as misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
|
||||
import { useDocumentVisibility } from '@/scripts/use-document-visibility';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
|
@ -104,9 +105,15 @@ const {
|
|||
enableInfiniteScroll,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const contentEl = $computed(() => props.pagination.pageEl || rootEl);
|
||||
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
||||
const scrollableElement = $computed(() => getScrollContainer(contentEl));
|
||||
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
let isPausingUpdate = false;
|
||||
let timerForSetPause: number | null = null;
|
||||
const BACKGROUND_PAUSE_WAIT_SEC = 10;
|
||||
|
||||
// 先頭が表示されているかどうかを検出
|
||||
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
|
||||
let scrollObserver = $ref<IntersectionObserver>();
|
||||
|
@ -279,6 +286,28 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
|
||||
|
||||
watch(visibility, () => {
|
||||
if (visibility.value === 'hidden') {
|
||||
timerForSetPause = window.setTimeout(() => {
|
||||
isPausingUpdate = true;
|
||||
timerForSetPause = null;
|
||||
},
|
||||
BACKGROUND_PAUSE_WAIT_SEC * 1000);
|
||||
} else { // 'visible'
|
||||
if (timerForSetPause) {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
} else {
|
||||
isPausingUpdate = false;
|
||||
if (isTop()) {
|
||||
executeQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const prepend = (item: MisskeyEntity): void => {
|
||||
// 初回表示時はunshiftだけでOK
|
||||
if (!rootEl) {
|
||||
|
@ -286,9 +315,7 @@ const prepend = (item: MisskeyEntity): void => {
|
|||
return;
|
||||
}
|
||||
|
||||
const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
|
||||
|
||||
if (isTop) unshiftItems([item]);
|
||||
if (isTop() && !isPausingUpdate) unshiftItems([item]);
|
||||
else prependQueue(item);
|
||||
};
|
||||
|
||||
|
@ -357,6 +384,10 @@ onMounted(() => {
|
|||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerForSetPause) {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
}
|
||||
scrollObserver.disconnect();
|
||||
});
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<MkInfo v-if="localOnly && channel == null" warn :class="$style.disableFederationWarn">{{ i18n.ts.disableFederationWarn }}</MkInfo>
|
||||
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text, { [$style.withCw]: useCw }]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
|
@ -73,7 +74,6 @@ import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
|
|||
import * as mfm from 'mfm-js';
|
||||
import * as misskey from 'misskey-js';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { length } from 'stringz';
|
||||
import { toASCII } from 'punycode/';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
|
@ -155,7 +155,7 @@ let autocomplete = $ref(null);
|
|||
let draghover = $ref(false);
|
||||
let quoteId = $ref(null);
|
||||
let hasNotSpecifiedMentions = $ref(false);
|
||||
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') || '[]'));
|
||||
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'));
|
||||
let imeText = $ref('');
|
||||
|
||||
const draftKey = $computed((): string => {
|
||||
|
@ -201,7 +201,7 @@ const submitText = $computed((): string => {
|
|||
});
|
||||
|
||||
const textLength = $computed((): number => {
|
||||
return length((text + imeText).trim());
|
||||
return (text + imeText).trim().length;
|
||||
});
|
||||
|
||||
const maxTextLength = $computed((): number => {
|
||||
|
@ -534,7 +534,7 @@ function onDrop(ev): void {
|
|||
}
|
||||
|
||||
function saveDraft() {
|
||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') || '{}');
|
||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
|
||||
|
||||
draftData[draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
|
@ -643,7 +643,7 @@ async function post(ev?: MouseEvent) {
|
|||
emit('posted');
|
||||
if (postData.text && postData.text !== '') {
|
||||
const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
|
||||
const history = JSON.parse(miLocalStorage.getItem('hashtags') || '[]') as string[];
|
||||
const history = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]') as string[];
|
||||
miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history))));
|
||||
}
|
||||
posting = false;
|
||||
|
@ -747,7 +747,7 @@ onMounted(() => {
|
|||
nextTick(() => {
|
||||
// 書きかけの投稿を復元
|
||||
if (!props.instant && !props.mention && !props.specified) {
|
||||
const draft = JSON.parse(miLocalStorage.getItem('drafts') || '{}')[draftKey];
|
||||
const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey];
|
||||
if (draft) {
|
||||
text = draft.data.text;
|
||||
useCw = draft.data.useCw;
|
||||
|
@ -942,6 +942,10 @@ defineExpose({
|
|||
background: var(--X4);
|
||||
}
|
||||
|
||||
.disableFederationWarn {
|
||||
margin: 0 20px 16px 20px;
|
||||
}
|
||||
|
||||
.hasNotSpecifiedMentions {
|
||||
margin: 0 20px 16px 20px;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
<template>
|
||||
<MkA v-adaptive-bg :to="`/admin/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
|
||||
<MkA v-adaptive-bg :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
|
||||
<div :class="$style.title">
|
||||
<span :class="$style.icon">
|
||||
<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
|
||||
<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
|
||||
<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
|
||||
<template v-if="role.iconUrl">
|
||||
<img :class="$style.badge" :src="role.iconUrl"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
|
||||
<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
|
||||
<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
|
||||
</template>
|
||||
</span>
|
||||
<span :class="$style.name">{{ role.name }}</span>
|
||||
<span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
|
||||
|
@ -20,6 +25,7 @@ import { i18n } from '@/i18n';
|
|||
|
||||
const props = defineProps<{
|
||||
role: any;
|
||||
forModeration: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
@ -38,6 +44,11 @@ const props = defineProps<{
|
|||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
height: 1.3em;
|
||||
vertical-align: -20%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval';
|
|||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
modelValue: string | null;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
@ -48,7 +48,7 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'change', _ev: KeyboardEvent): void;
|
||||
(ev: 'update:modelValue', value: string): void;
|
||||
(ev: 'update:modelValue', value: string | null): void;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
|
||||
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :with-password-toggle="true" required data-cy-signin-password>
|
||||
<template #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
||||
</MkInput>
|
||||
|
@ -28,11 +28,11 @@
|
|||
</div>
|
||||
<div class="twofa-group totp-group">
|
||||
<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
|
||||
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required>
|
||||
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :with-password-toggle="true" required>
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
<template #prefix><i class="ti ti-lock"></i></template>
|
||||
</MkInput>
|
||||
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required>
|
||||
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required>
|
||||
<template #label>{{ i18n.ts.token }}</template>
|
||||
<template #prefix><i class="ti ti-123"></i></template>
|
||||
</MkInput>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
|
||||
<MkNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide, onUnmounted } from 'vue';
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import { stream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { $i } from '@/account';
|
||||
|
@ -24,7 +24,7 @@ const emit = defineEmits<{
|
|||
|
||||
provide('inChannel', computed(() => props.src === 'channel'));
|
||||
|
||||
const tlComponent: InstanceType<typeof XNotes> = $ref();
|
||||
const tlComponent: InstanceType<typeof MkNotes> = $ref();
|
||||
|
||||
const prepend = note => {
|
||||
tlComponent.pagingComponent?.prepend(note);
|
||||
|
|
|
@ -1,12 +1,25 @@
|
|||
<template>
|
||||
<div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button>
|
||||
<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
<span v-else>invalid url</span>
|
||||
</div>
|
||||
<div v-else-if="tweetId && tweetExpanded" ref="twitter" :class="$style.twitter">
|
||||
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe>
|
||||
</div>
|
||||
<template v-if="playerEnabled">
|
||||
<div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
<span v-else>invalid url</span>
|
||||
</div>
|
||||
<div :class="$style.action">
|
||||
<MkButton :small="true" inline @click="playerEnabled = false">
|
||||
<i class="ti ti-x"></i> {{ i18n.ts.disablePlayer }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="tweetId && tweetExpanded">
|
||||
<div ref="twitter" :class="$style.twitter">
|
||||
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe>
|
||||
</div>
|
||||
<div :class="$style.action">
|
||||
<MkButton :small="true" inline @click="tweetExpanded = false">
|
||||
<i class="ti ti-x"></i> {{ i18n.ts.close }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :class="$style.urlPreview">
|
||||
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
|
||||
<div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`">
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: users }">
|
||||
<template #default="{ items }">
|
||||
<div class="efvhhmdq">
|
||||
<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
|
||||
<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
|
@ -20,10 +20,13 @@ import MkUserInfo from '@/components/MkUserInfo.vue';
|
|||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
noGap?: boolean;
|
||||
}>();
|
||||
extractor?: (item: any) => any;
|
||||
}>(), {
|
||||
extractor: (item) => item,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="host" @update:model-value="search">
|
||||
<MkInput v-model="host" :datalist="[hostname]" @update:model-value="search">
|
||||
<template #label>{{ i18n.ts.host }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
|
@ -61,6 +61,7 @@ import * as os from '@/os';
|
|||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { hostname } from '@/config';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', selected: misskey.entities.UserDetailed): void;
|
||||
|
@ -115,7 +116,7 @@ onMounted(() => {
|
|||
os.api('users/show', {
|
||||
userIds: defaultStore.state.recentlyUsedUsers,
|
||||
}).then(users => {
|
||||
if (props.includeSelf) {
|
||||
if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) {
|
||||
recentUsers = [$i, ...users];
|
||||
} else {
|
||||
recentUsers = users;
|
||||
|
|
|
@ -33,8 +33,8 @@
|
|||
<button key="localOnly" class="_button" :class="[$style.item, $style.localOnly, { [$style.active]: localOnly }]" data-index="5" @click="localOnly = !localOnly">
|
||||
<div :class="$style.icon"><i class="ti ti-world-off"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.localOnly }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.localOnlyDescription }}</span>
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.disableFederation }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.disableFederationDescription }}</span>
|
||||
</div>
|
||||
<div :class="$style.toggle"><i :class="localOnly ? 'ti ti-toggle-right' : 'ti ti-toggle-left'"></i></div>
|
||||
</button>
|
||||
|
|
|
@ -24,7 +24,7 @@ const rawUrl = computed(() => {
|
|||
return props.url;
|
||||
}
|
||||
if (props.host == null && !customEmojiName.value.includes('@')) {
|
||||
return customEmojis.value.find(x => x.name === customEmojiName.value)?.url || null;
|
||||
return customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null;
|
||||
}
|
||||
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
|
||||
});
|
||||
|
@ -32,7 +32,7 @@ const rawUrl = computed(() => {
|
|||
const url = computed(() =>
|
||||
defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value
|
||||
? getStaticImageUrl(rawUrl.value)
|
||||
: rawUrl.value
|
||||
: rawUrl.value,
|
||||
);
|
||||
|
||||
const alt = computed(() => `:${customEmojiName.value}:`);
|
||||
|
@ -41,7 +41,7 @@ let errored = $ref(url.value == null);
|
|||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
height: 2.5em;
|
||||
height: 2em;
|
||||
vertical-align: middle;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
|
|
|
@ -1,23 +1,33 @@
|
|||
<template>
|
||||
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
|
||||
<div :class="$style.tabsInner">
|
||||
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
|
||||
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
|
||||
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
|
||||
<div :class="$style.tabInner">
|
||||
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
|
||||
<div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
|
||||
:class="$style.tabTitle">{{ t.title }}</div>
|
||||
<Transition v-else mode="in-out" @enter="enter" @after-enter="afterEnter" @leave="leave"
|
||||
@after-leave="afterLeave">
|
||||
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
|
||||
</Transition>
|
||||
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
|
||||
<div :class="$style.tabsInner">
|
||||
<button
|
||||
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
|
||||
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
|
||||
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
|
||||
>
|
||||
<div :class="$style.tabInner">
|
||||
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
|
||||
<div
|
||||
v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
|
||||
:class="$style.tabTitle"
|
||||
>
|
||||
{{ t.title }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="tabHighlightEl"
|
||||
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></div>
|
||||
<Transition
|
||||
v-else mode="in-out" @enter="enter" @after-enter="afterEnter" @leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="tabHighlightEl"
|
||||
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -93,7 +103,7 @@ function onTabWheel(ev: WheelEvent) {
|
|||
ev.stopPropagation();
|
||||
(ev.currentTarget as HTMLElement).scrollBy({
|
||||
left: ev.deltaY,
|
||||
behavior: 'smooth',
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
return false;
|
||||
|
@ -206,8 +216,8 @@ onUnmounted(() => {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.tabIcon+.tabTitle {
|
||||
padding-left: 8px;
|
||||
.tabIcon + .tabTitle {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
|
||||
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
|
||||
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
|
||||
<MkAvatar :class="$style.avatar" :user="$i" />
|
||||
<MkAvatar :class="$style.avatar" :user="$i"/>
|
||||
</div>
|
||||
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft" />
|
||||
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft"/>
|
||||
|
||||
<template v-if="metadata">
|
||||
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
||||
|
@ -36,11 +36,11 @@
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref, inject } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
|
||||
import { scrollToTop } from '@/scripts/scroll';
|
||||
import { globalEvents } from '@/events';
|
||||
import { injectPageMetadata } from '@/scripts/page-metadata';
|
||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
|
||||
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tabs?: Tab[];
|
||||
|
@ -96,7 +96,7 @@ function onTabClick(): void {
|
|||
}
|
||||
|
||||
const calcBg = () => {
|
||||
const rawBg = metadata?.bg || 'var(--bg)';
|
||||
const rawBg = metadata?.bg ?? 'var(--bg)';
|
||||
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
|
||||
tinyBg.setAlpha(0.85);
|
||||
bg.value = tinyBg.toRgbString();
|
||||
|
@ -147,10 +147,7 @@ onUnmounted(() => {
|
|||
|
||||
.tabs:first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
.tabs:not(:first-child) {
|
||||
padding-left: 16px;
|
||||
mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%);
|
||||
padding: 0 12px;
|
||||
}
|
||||
.tabs {
|
||||
margin-right: auto;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<time :title="absolute">
|
||||
<template v-if="mode === 'relative'">{{ relative }}</template>
|
||||
<template v-if="invalid">{{ i18n.ts._ago.invalid }}</template>
|
||||
<template v-else-if="mode === 'relative'">{{ relative }}</template>
|
||||
<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
|
||||
<template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
|
||||
</time>
|
||||
|
@ -12,18 +13,24 @@ import { i18n } from '@/i18n';
|
|||
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
time: Date | string;
|
||||
time: Date | string | number | null;
|
||||
mode?: 'relative' | 'absolute' | 'detail';
|
||||
}>(), {
|
||||
mode: 'relative',
|
||||
});
|
||||
|
||||
const _time = typeof props.time === 'string' ? new Date(props.time) : props.time;
|
||||
const absolute = dateTimeFormat.format(_time);
|
||||
const _time = props.time == null ? NaN :
|
||||
typeof props.time === 'number' ? props.time :
|
||||
(props.time instanceof Date ? props.time : new Date(props.time)).getTime();
|
||||
const invalid = Number.isNaN(_time);
|
||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||
|
||||
let now = $shallowRef(new Date());
|
||||
const relative = $computed(() => {
|
||||
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
|
||||
let now = $ref((new Date()).getTime());
|
||||
const relative = $computed<string>(() => {
|
||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
return (
|
||||
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
|
||||
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
|
||||
|
@ -39,8 +46,8 @@ const relative = $computed(() => {
|
|||
let tickId: number;
|
||||
|
||||
function tick() {
|
||||
now = new Date();
|
||||
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
|
||||
now = (new Date()).getTime();
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
|
||||
tickId = window.setTimeout(tick, next);
|
||||
|
|
|
@ -278,7 +278,7 @@ export default defineComponent({
|
|||
case 'hashtag': {
|
||||
return [h(MkA, {
|
||||
key: Math.random(),
|
||||
to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
|
||||
to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
|
||||
style: 'color:var(--hashtag);',
|
||||
}, `#${token.props.hashtag}`)];
|
||||
}
|
||||
|
|
|
@ -43,3 +43,6 @@ https://github.com/sindresorhus/file-type/blob/main/supported.js
|
|||
https://github.com/sindresorhus/file-type/blob/main/core.js
|
||||
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
|
||||
*/
|
||||
|
||||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
|
||||
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
|
||||
|
|
|
@ -36,7 +36,6 @@ import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
|
|||
import { defaultStore, ColdDeviceStorage } from '@/store';
|
||||
import { fetchInstance, instance } from '@/instance';
|
||||
import { makeHotkey } from '@/scripts/hotkey';
|
||||
import { search } from '@/scripts/search';
|
||||
import { deviceKind } from '@/scripts/device-kind';
|
||||
import { initializeSw } from '@/scripts/initialize-sw';
|
||||
import { reloadChannel } from '@/scripts/unison-reload';
|
||||
|
@ -47,6 +46,7 @@ import { deckStore } from './ui/deck/deck-store';
|
|||
import { miLocalStorage } from './local-storage';
|
||||
import { claimAchievement, claimedAchievements } from './scripts/achievements';
|
||||
import { fetchCustomEmojis } from './custom-emojis';
|
||||
import { mainRouter } from './router';
|
||||
|
||||
console.info(`Misskey v${version}`);
|
||||
|
||||
|
@ -352,7 +352,9 @@ const hotkeys = {
|
|||
'd': (): void => {
|
||||
defaultStore.set('darkMode', !defaultStore.state.darkMode);
|
||||
},
|
||||
's': search,
|
||||
's': (): void => {
|
||||
mainRouter.push('/search');
|
||||
}
|
||||
};
|
||||
|
||||
if ($i) {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { computed, reactive } from 'vue';
|
||||
import { $i } from './account';
|
||||
import { miLocalStorage } from './local-storage';
|
||||
import { search } from '@/scripts/search';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { ui } from '@/config';
|
||||
|
@ -42,7 +41,7 @@ export const navbarItemDef = reactive({
|
|||
search: {
|
||||
title: i18n.ts.search,
|
||||
icon: 'ti ti-search',
|
||||
action: () => search(),
|
||||
to: '/search',
|
||||
},
|
||||
lists: {
|
||||
title: i18n.ts.lists,
|
||||
|
|
|
@ -246,7 +246,10 @@ export function inputText(props: {
|
|||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: string;
|
||||
}> {
|
||||
|
@ -257,7 +260,10 @@ export function inputText(props: {
|
|||
input: {
|
||||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
|
@ -271,6 +277,7 @@ export function inputNumber(props: {
|
|||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: number | null;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: number;
|
||||
|
@ -282,6 +289,7 @@ export function inputNumber(props: {
|
|||
input: {
|
||||
type: 'number',
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
},
|
||||
}, {
|
||||
|
@ -595,9 +603,3 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> {
|
|||
});
|
||||
});
|
||||
}*/
|
||||
|
||||
export const shownNoteIds = new Set();
|
||||
|
||||
window.setInterval(() => {
|
||||
shownNoteIds.clear();
|
||||
}, 1000 * 60 * 5);
|
||||
|
|
|
@ -84,6 +84,10 @@
|
|||
</div>
|
||||
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
<template #label>Credits</template>
|
||||
<p>Misskeyで使われる画像の一部は、許可を得て「あの子がこっちを見てるメーカー」で作成したものが含まれます。</p>
|
||||
</FormSection>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
|
@ -111,6 +115,12 @@ const patronsWithIcon = [{
|
|||
}, {
|
||||
name: 'だれかさん',
|
||||
icon: 'https://misskey-hub.net/patrons/f7409b5e5a88477a9b9d740c408de125.jpg',
|
||||
}, {
|
||||
name: 'narazaka',
|
||||
icon: 'https://misskey-hub.net/patrons/e3affff31ffb4877b1196c7360abc3e5.jpg',
|
||||
}, {
|
||||
name: 'ひとぅ',
|
||||
icon: 'https://misskey-hub.net/patrons/8cc0d0a0a6d84c88bca1aedabf6ed5ab.jpg',
|
||||
}];
|
||||
|
||||
const patrons = [
|
||||
|
|
|
@ -113,7 +113,7 @@ function onTabClick(tab: Tab, ev: MouseEvent): void {
|
|||
}
|
||||
|
||||
const calcBg = () => {
|
||||
const rawBg = metadata?.bg || 'var(--bg)';
|
||||
const rawBg = metadata?.bg ?? 'var(--bg)';
|
||||
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
|
||||
tinyBg.setAlpha(0.85);
|
||||
bg.value = tinyBg.toRgbString();
|
||||
|
|
|
@ -44,6 +44,9 @@
|
|||
<MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<MkButton class="button" @click="more()">
|
||||
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
|
@ -123,7 +126,21 @@ function save(ad) {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
function more() {
|
||||
os.api('admin/ad/list', { untilId: ads.reduce((acc, ad) => ad.id != null ? ad : acc).id }).then(adsResponse => {
|
||||
ads = ads.concat(adsResponse.map(r => {
|
||||
const exdate = new Date(r.expiresAt);
|
||||
const stdate = new Date(r.startsAt);
|
||||
exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff);
|
||||
stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff);
|
||||
return {
|
||||
...r,
|
||||
expiresAt: exdate.toISOString().slice(0, 16),
|
||||
startsAt: stdate.toISOString().slice(0, 16),
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
const headerActions = $computed(() => [{
|
||||
asFullButton: true,
|
||||
icon: 'ti ti-plus',
|
||||
|
|
|
@ -16,16 +16,29 @@
|
|||
<MkFolder v-if="role.target === 'manual'" default-open>
|
||||
<template #icon><i class="ti ti-users"></i></template>
|
||||
<template #label>{{ i18n.ts.users }}</template>
|
||||
<template #suffix>{{ role.users.length }}</template>
|
||||
<template #suffix>{{ role.usersCount }}</template>
|
||||
<div class="_gaps">
|
||||
<MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
|
||||
|
||||
<div v-for="user in role.users" :key="user.id" :class="$style.userItem">
|
||||
<MkA :class="$style.user" :to="`/user-info/${user.id}`">
|
||||
<MkUserCardMini :user="user"/>
|
||||
</MkA>
|
||||
<button class="_button" :class="$style.unassign" @click="unassign(user, $event)"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
<MkPagination :pagination="usersPagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
<div v-for="item in items" :key="item.user.id" :class="$style.userItem">
|
||||
<MkA :class="$style.user" :to="`/user-info/${item.user.id}`">
|
||||
<MkUserCardMini :user="item.user"/>
|
||||
</MkA>
|
||||
<button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo>
|
||||
|
@ -47,6 +60,7 @@ import { useRouter } from '@/router';
|
|||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -54,6 +68,14 @@ const props = defineProps<{
|
|||
id?: string;
|
||||
}>();
|
||||
|
||||
const usersPagination = {
|
||||
endpoint: 'admin/roles/users' as const,
|
||||
limit: 20,
|
||||
params: computed(() => ({
|
||||
roleId: props.id,
|
||||
})),
|
||||
};
|
||||
|
||||
const role = reactive(await os.api('admin/roles/show', {
|
||||
roleId: props.id,
|
||||
}));
|
||||
|
@ -114,6 +136,7 @@ definePageMetadata(computed(() => ({
|
|||
|
||||
.user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.unassign {
|
||||
|
|