diff options
| author | Kagami Sascha Rosylight <saschanaz@outlook.com> | 2023-02-25 20:04:48 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-02-25 20:04:48 +0100 |
| commit | b468330ed944cd2aefb93183786855e990bd3df3 (patch) | |
| tree | aae515a3d90bc6646854ea718c054540b2b654e9 /packages | |
| parent | Add test (diff) | |
| parent | refactor(frontend): fix eslint error (#10084) (diff) | |
| download | misskey-b468330ed944cd2aefb93183786855e990bd3df3.tar.gz misskey-b468330ed944cd2aefb93183786855e990bd3df3.tar.bz2 misskey-b468330ed944cd2aefb93183786855e990bd3df3.zip | |
Merge branch 'develop' into mkusername-empty
Diffstat (limited to 'packages')
158 files changed, 2344 insertions, 1253 deletions
diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 55a88456ef..08d4222d01 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -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 } diff --git a/packages/backend/assets/apple-touch-icon.png b/packages/backend/assets/apple-touch-icon.png Binary files differindex 947c513bbb..06ad3f1bb4 100644 --- a/packages/backend/assets/apple-touch-icon.png +++ b/packages/backend/assets/apple-touch-icon.png diff --git a/packages/backend/assets/icons/192.png b/packages/backend/assets/icons/192.png Binary files differindex 606b46d87c..15fd1e3731 100644 --- a/packages/backend/assets/icons/192.png +++ b/packages/backend/assets/icons/192.png diff --git a/packages/backend/assets/icons/512.png b/packages/backend/assets/icons/512.png Binary files differindex ba51546427..f2169ec9b0 100644 --- a/packages/backend/assets/icons/512.png +++ b/packages/backend/assets/icons/512.png diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js new file mode 100644 index 0000000000..8bf134a105 --- /dev/null +++ b/packages/backend/check_connect.js @@ -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; +}); diff --git a/packages/backend/migration/1676434944993-drop-group.js b/packages/backend/migration/1676434944993-drop-group.js index bdf3c1034e..c856046eb9 100644 --- a/packages/backend/migration/1676434944993-drop-group.js +++ b/packages/backend/migration/1676434944993-drop-group.js @@ -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"`); diff --git a/packages/backend/migration/1676438468213-ad3.js b/packages/backend/migration/1676438468213-ad3.js index bf1f384adc..18f56e8d36 100644 --- a/packages/backend/migration/1676438468213-ad3.js +++ b/packages/backend/migration/1676438468213-ad3.js @@ -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"`); } } diff --git a/packages/backend/package.json b/packages/backend/package.json index 52b0d74ad0..4d1d37efff 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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" } -} +}
\ No newline at end of file diff --git a/packages/backend/src/@types/redis-lock.d.ts b/packages/backend/src/@types/redis-lock.d.ts new file mode 100644 index 0000000000..9242656a98 --- /dev/null +++ b/packages/backend/src/@types/redis-lock.d.ts @@ -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; +} diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index 5f3072a415..ee179b7f01 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -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) diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 8d961452c7..9b2d5dc0ff 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -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; }, diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 75bf4b0e01..2cad1bc07e 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -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; diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 9bd80b6066..6d9569bce2 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -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); diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index bfd53dfabf..71fbc29476 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -28,31 +28,15 @@ type PrivateKey = { keyId: string; }; -@Injectable() -export class ApRequestService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - private userKeypairStoreService: UserKeypairStoreService, - private httpRequestService: HttpRequestService, - private loggerService: LoggerService, - ) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - 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 { +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({ + headers: this.#objectAssignWithLcKey({ 'Date': new Date().toUTCString(), 'Host': u.host, 'Content-Type': 'application/activity+json', @@ -60,7 +44,7 @@ export class ApRequestService { }, args.additionalHeaders), }; - const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); return { request, @@ -70,21 +54,20 @@ export class ApRequestService { }; } - @bindThis - private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed { + 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({ + 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']); + const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); return { request, @@ -94,13 +77,12 @@ export class ApRequestService { }; } - @bindThis - private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { - const signingString = this.genSigningString(request, includeHeaders); + 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, { + 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! @@ -114,9 +96,8 @@ export class ApRequestService { }; } - @bindThis - private genSigningString(request: Request, includeHeaders: string[]): string { - request.headers = this.lcObjectKey(request.headers); + static #genSigningString(request: Request, includeHeaders: string[]): string { + request.headers = this.#lcObjectKey(request.headers); const results: string[] = []; @@ -131,16 +112,31 @@ export class ApRequestService { return results.join('\n'); } - @bindThis - private lcObjectKey(src: Record<string, string>): Record<string, string> { + 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; } - @bindThis - private objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> { - return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b)); + 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; + + constructor( + @Inject(DI.config) + private config: Config, + + private userKeypairStoreService: UserKeypairStoreService, + private httpRequestService: HttpRequestService, + private loggerService: LoggerService, + ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる } @bindThis @@ -149,7 +145,7 @@ export class ApRequestService { 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`, diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 268bf99119..7f2ca9c05e 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -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; diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 34ff52ede8..33c76c6937 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -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, diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index f2ba642375..80ef5ac1fa 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -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))); } } diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 105f0d0407..51117efba5 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -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]; diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index 3d35b4cb5a..60c1c55de5 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -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: [], diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index cc27b36966..347fa59d36 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -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']; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 30101a2c60..d3e2219bd5 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -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, diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index c7f9916f97..c94884a78c 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -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; } /** diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index f1164b9957..bd3d8a28da 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -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) { diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index 115526d997..ed283eb834 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -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) => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d05005b078..4d5ed9fb62 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -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], diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 6376cb153c..85b566aabe 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -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, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts index ac56de56b9..edaf638ea9 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/list.ts @@ -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); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts index c83f96191d..01028a086f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/show.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/show.ts @@ -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); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts new file mode 100644 index 0000000000..bb016a8425 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -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 }), + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 823af6d8be..9d19efbbcf 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -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), }; }); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index ec9ac1ef90..6c31075e05 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -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, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 6e0849f2b2..ad33398da6 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -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; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index 0655a86350..0ee9f556a8 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -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, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a539c5c221..eb4d7f9c14 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 0f2b0fd7ee..4b726aed80 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 4c5b151f78..e0e7ba6658 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -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, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts new file mode 100644 index 0000000000..d98f60fa5f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -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 {}; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 706e0d2089..e3897d38bd 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -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) { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 593444968e..f4c5a84a4f 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -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; diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 8eff8fdb22..cf939f6631 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -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()); diff --git a/packages/backend/src/server/api/endpoints/roles/list.ts b/packages/backend/src/server/api/endpoints/roles/list.ts new file mode 100644 index 0000000000..d61c6b8dc6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/list.ts @@ -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); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts new file mode 100644 index 0000000000..cc755dcc76 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/show.ts @@ -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); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts new file mode 100644 index 0000000000..6e221b6c67 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -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 }), + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 8b22f913d2..1cefcf2707 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -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'); + 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()) + '%', + }); + } + } - const users = await q.take(ps.limit).getMany(); + return query; + }; - return await this.userEntityService.packMany(users, me, { detail: ps.detail }); - } else if (ps.username) { - let users: User[] = []; + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + let users: User[] = []; - 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 }); - })); + if (me) { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - query.setParameters(followingQuery.getParameters()); + 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 }); + })); - users = await query - .orderBy('user.usernameLower', 'ASC') - .take(ps.limit) - .getMany(); + query.setParameters(followingQuery.getParameters()); - 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'); + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit) + .getMany(); - otherQuery.setParameters(followingQuery.getParameters()); + 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'); - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) - .getMany(); + otherQuery.setParameters(followingQuery.getParameters()); - users = users.concat(otherUsers); - } - } 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') + const otherUsers = await otherQuery .orderBy('user.updatedAt', 'DESC') .take(ps.limit - users.length) .getMany(); + + users = users.concat(otherUsers); } + } else { + const query = setUsernameAndHostQuery() + .andWhere('user.isSuspended = FALSE') + .andWhere('user.updatedAt IS NOT NULL'); - return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); + users = await query + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); } - return []; + return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); }); } } diff --git a/packages/backend/src/server/web/manifest.json b/packages/backend/src/server/web/manifest.json index 48030a2980..41171d62a1 100644 --- a/packages/backend/src/server/web/manifest.json +++ b/packages/backend/src/server/web/manifest.json @@ -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", diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8ac65a021c..7c6a1e5199 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -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; diff --git a/packages/backend/test/tests/mfm.ts b/packages/backend/test/tests/mfm.ts deleted file mode 100644 index 884f39d7fb..0000000000 --- a/packages/backend/test/tests/mfm.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/backend/test/tests/reaction-lib.ts b/packages/backend/test/tests/reaction-lib.ts deleted file mode 100644 index 2e767f7697..0000000000 --- a/packages/backend/test/tests/reaction-lib.ts +++ /dev/null @@ -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'); - }); -}); -*/ diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 5d91d0923a..da82ddc4a1 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -37,6 +37,7 @@ }, "compileOnSave": false, "include": [ - "./**/*.ts" + "./**/*.ts", + "../src/@types/**/*.ts", ] } diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts new file mode 100644 index 0000000000..5496738778 --- /dev/null +++ b/packages/backend/test/unit/MfmService.ts @@ -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'); + }); + }); +}); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts new file mode 100644 index 0000000000..6a20a1e08e --- /dev/null +++ b/packages/backend/test/unit/ReactionService.ts @@ -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'), '👍'); + }); + }); +}); diff --git a/packages/backend/test/tests/ap-request.ts b/packages/backend/test/unit/ap-request.ts index 8c586861ad..98f352e1c6 100644 --- a/packages/backend/test/tests/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -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'); diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 1e9a51bc88..5ac4cc18a2 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -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; diff --git a/packages/backend/test/tests/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts index e81d04c2db..66d32be1c5 100644 --- a/packages/backend/test/tests/extract-mentions.ts +++ b/packages/backend/test/unit/extract-mentions.ts @@ -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', diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 544b529e94..6f335a2442 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "allowJs": true, - "noEmitOnError": false, + "noEmitOnError": true, "noImplicitAny": true, "noImplicitReturns": true, "noUnusedParameters": false, diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js index 6c3bfb5a6e..e8e0e57d2a 100644 --- a/packages/frontend/.eslintrc.js +++ b/packages/frontend/.eslintrc.js @@ -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', diff --git a/packages/frontend/assets/about-icon.png b/packages/frontend/assets/about-icon.png Binary files differindex afc1f0c728..15fd1e3731 100644 --- a/packages/frontend/assets/about-icon.png +++ b/packages/frontend/assets/about-icon.png diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 61c0135ba3..24f8d9b6a6 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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" } -} +}
\ No newline at end of file diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a76a1e0f54..9f2bf99338 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -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', { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 7e5432434f..663c57623d 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -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}`; diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 8db2e54e88..c72cc2ab1b 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -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, diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 2283e652db..da6439fd2c 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -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); diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index f0ea984c4e..21cccaabde 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -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) { diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index e0885f5550..7d5579040a 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -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(' / '); diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 9690353432..863ea702cd 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -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 }); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index a1d7210d7e..b97e36cd5f 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -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; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index da6177c2f9..3e3d7354c1 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -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[]; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index e957d8f56c..a12bb78e35 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -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; }); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index cdd9d96b96..e0935efbe7 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -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> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 52aba58455..09d530c4ea 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -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; } } diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index eba0f5847d..dbad02fb7e 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -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; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index f4c044e0bd..1040dac12e 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -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; } } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 82e0f3e689..2eebe999a5 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -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, }); }, diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 32998e1a70..ffd9a20ef7 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -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> diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue index e303403872..f6d0e5681d 100644 --- a/packages/frontend/src/components/MkNotificationSettingWindow.vue +++ b/packages/frontend/src/components/MkNotificationSettingWindow.vue @@ -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> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 37ce7635a3..93b1c37055 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -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][]; diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 98115dd424..02ce58451d 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -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(); } diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 224a42cdc2..378d0ac020 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -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(); }); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 44462f8ff2..f73eab5b86 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -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; } diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 8c1d7af190..2f5866f340 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -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; } diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index cb64b1e484..2de890186a 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -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(); diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index ae4f38e56c..ffc5e82b56 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -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> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 1ba48bf77d..87f7c61a92 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -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); diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index b97b7cf07b..5381ecbfa5 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -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}')`"> diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index dd683fcc23..51eb426e97 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -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> diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 981ae56e6c..dc78bbf42d 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -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; diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 516b88c13d..703c75c7d0 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -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> diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index e6dedd0354..84aae1cff8 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -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; diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index b181b62986..42760da08f 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -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 { diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 98233b02e0..589ca92d75 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -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; diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 66c0bd5135..3fa8bb9adc 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -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); diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts index 1b1d27ea2a..e84eabcbcc 100644 --- a/packages/frontend/src/components/mfm.ts +++ b/packages/frontend/src/components/mfm.ts @@ -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}`)]; } diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 77366cf07b..1d44786a63 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -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; diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index 8c657295f9..0a626b36c6 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -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) { diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 95bf6e8181..48bece05fa 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -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, diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 6bff12661f..7e38fe5f6d 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -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); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index bc3d248193..782fe9fdb2 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -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 = [ diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index 599b16e465..372c63ff4c 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -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(); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index e5431b177c..828bfe6007 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -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', diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 0365165b5d..e7d57ad4f0 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -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 { diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 19a0f6617f..d89f0d2a7d 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -133,7 +133,7 @@ </div> </MkFolder> <div class="_gaps_s"> - <MkRolePreview v-for="role in roles" :key="role.id" :role="role"/> + <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="true"/> </div> </div> </MkSpacer> diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 44c79b623a..cf803d6c7f 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -4,7 +4,7 @@ <div ref="rootEl" v-hotkey.global="keymap" class="tqmomfks"> <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> <div class="tl"> - <XTimeline + <MkTimeline ref="tlEl" :key="antennaId" class="tl" src="antenna" @@ -19,7 +19,7 @@ <script lang="ts" setup> import { computed, watch } from 'vue'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; import { useRouter } from '@/router'; @@ -35,7 +35,7 @@ const props = defineProps<{ let antenna = $ref(null); let queue = $ref(0); let rootEl = $shallowRef<HTMLElement>(); -let tlEl = $shallowRef<InstanceType<typeof XTimeline>>(); +let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); const keymap = $computed(() => ({ 't': focus, })); diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 50afffc460..4f8afb9ea2 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -1,12 +1,12 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs" /></template> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="500"> <div v-if="state == 'fetch-session-error'"> <p>{{ i18n.ts.somethingHappened }}</p> </div> <div v-else-if="$i && !session"> - <MkLoading /> + <MkLoading/> </div> <div v-else-if="$i && session"> <XForm @@ -21,15 +21,16 @@ </div> <div v-if="state == 'accepted' && session"> <h1>{{ session.app.isAuthorized ? $t('already-authorized') : i18n.ts.allowed }}</h1> - <p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }} - <MkEllipsis /> + <p v-if="session.app.callbackUrl"> + {{ i18n.ts._auth.callback }} + <MkEllipsis/> </p> <p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p> </div> </div> <div v-else> <p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> - <MkSignin @login="onLogin" /> + <MkSignin @login="onLogin"/> </div> </MkSpacer> </MkStickyContainer> @@ -37,12 +38,12 @@ <script lang="ts" setup> import { onMounted } from 'vue'; +import { AuthSession } from 'misskey-js/built/entities'; import XForm from './auth.form.vue'; import MkSignin from '@/components/MkSignin.vue'; import * as os from '@/os'; import { $i, login } from '@/account'; import { definePageMetadata } from '@/scripts/page-metadata'; -import { AuthSession } from 'misskey-js/built/entities'; import { i18n } from '@/i18n'; const props = defineProps<{ @@ -82,7 +83,7 @@ onMounted(async () => { } else { state = 'waiting'; } - } catch (e) { + } catch (err) { state = 'fetch-session-error'; } }); diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 0bfc772353..6b4fcb32f8 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -1,9 +1,9 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <div v-if="channel"> - <div class="wpgynlbz _panel _margin" :class="{ hide: !showBanner }"> + <div v-if="channel && tab === 'timeline'" class="_gaps"> + <div class="wpgynlbz _panel" :class="{ hide: !showBanner }"> <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> <button class="_button toggle" @click="() => showBanner = !showBanner"> <template v-if="showBanner"><i class="ti ti-chevron-up"></i></template> @@ -23,9 +23,13 @@ </div> </div> - <MkPostForm v-if="$i" :channel="channel" class="post-form _panel _margin" fixed/> + <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> + <MkPostForm v-if="$i" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> - <XTimeline :key="channelId" class="_margin" src="channel" :channel="channelId" @before="before" @after="after"/> + <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/> + </div> + <div v-else-if="tab === 'featured'"> + <MkNotes :pagination="featuredPagination"/> </div> </MkSpacer> </MkStickyContainer> @@ -34,13 +38,15 @@ <script lang="ts" setup> import { computed, watch } from 'vue'; import MkPostForm from '@/components/MkPostForm.vue'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os'; import { useRouter } from '@/router'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { deviceKind } from '@/scripts/device-kind'; +import MkNotes from '@/components/MkNotes.vue'; const router = useRouter(); @@ -48,15 +54,17 @@ const props = defineProps<{ channelId: string; }>(); +let tab = $ref('timeline'); let channel = $ref(null); let showBanner = $ref(true); -const pagination = { - endpoint: 'channels/timeline' as const, +const featuredPagination = $computed(() => ({ + endpoint: 'notes/featured' as const, limit: 10, - params: computed(() => ({ + offsetMode: true, + params: { channelId: props.channelId, - })), -}; + }, +})); watch(() => props.channelId, async () => { channel = await os.api('channels/show', { @@ -74,7 +82,15 @@ const headerActions = $computed(() => channel && channel.userId ? [{ handler: edit, }] : null); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'timeline', + title: i18n.ts.timeline, + icon: 'ti ti-home', +}, { + key: 'featured', + title: i18n.ts.featured, + icon: 'ti ti-bolt', +}]); definePageMetadata(computed(() => channel ? { title: channel.name, diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index cd9cec0d4f..d4e8f27005 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -12,7 +12,7 @@ </div> </div> - <XNotes :pagination="pagination" :detail="true"/> + <MkNotes :pagination="pagination" :detail="true"/> </div> </MkSpacer> </MkStickyContainer> @@ -21,7 +21,7 @@ <script lang="ts" setup> import { computed, watch, provide } from 'vue'; import * as misskey from 'misskey-js'; -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import * as os from '@/os'; diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index 18a371a086..a972ae04ec 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -4,13 +4,13 @@ <option value="notes">{{ i18n.ts.notes }}</option> <option value="polls">{{ i18n.ts.poll }}</option> </MkTab> - <XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> - <XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> + <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> </MkSpacer> </template> <script lang="ts" setup> -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n'; diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue new file mode 100644 index 0000000000..8be11008c2 --- /dev/null +++ b/packages/frontend/src/pages/explore.roles.vue @@ -0,0 +1,22 @@ +<template> +<MkSpacer :content-max="1200"> + <div class="_gaps_s"> + <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="false"/> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkRolePreview from '@/components/MkRolePreview.vue'; +import * as os from '@/os'; + +let roles = $ref(); + +os.api('roles/list', { + limit: 30, +}).then(res => { + roles = res; +}); +</script> + diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 4d6ac7d710..c441407d97 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -8,19 +8,19 @@ <template v-if="tag == null"> <MkFoldableSection class="_margin" persist-key="explore-pinned-users"> <template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template> - <XUserList :pagination="pinnedUsers"/> + <MkUserList :pagination="pinnedUsers"/> </MkFoldableSection> <MkFoldableSection class="_margin" persist-key="explore-popular-users"> <template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> - <XUserList :pagination="popularUsers"/> + <MkUserList :pagination="popularUsers"/> </MkFoldableSection> <MkFoldableSection class="_margin" persist-key="explore-recently-updated-users"> <template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> - <XUserList :pagination="recentlyUpdatedUsers"/> + <MkUserList :pagination="recentlyUpdatedUsers"/> </MkFoldableSection> <MkFoldableSection class="_margin" persist-key="explore-recently-registered-users"> <template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template> - <XUserList :pagination="recentlyRegisteredUsers"/> + <MkUserList :pagination="recentlyRegisteredUsers"/> </MkFoldableSection> </template> </div> @@ -29,28 +29,28 @@ <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> <div class="vxjfqztj"> - <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> - <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`">{{ tag.tag }}</MkA> </div> </MkFoldableSection> <MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin"> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> - <XUserList :pagination="tagUsers"/> + <MkUserList :pagination="tagUsers"/> </MkFoldableSection> <template v-if="tag == null"> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> - <XUserList :pagination="popularUsersF"/> + <MkUserList :pagination="popularUsersF"/> </MkFoldableSection> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> - <XUserList :pagination="recentlyUpdatedUsersF"/> + <MkUserList :pagination="recentlyUpdatedUsersF"/> </MkFoldableSection> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template> - <XUserList :pagination="recentlyRegisteredUsersF"/> + <MkUserList :pagination="recentlyRegisteredUsersF"/> </MkFoldableSection> </template> </div> @@ -59,7 +59,7 @@ <script lang="ts" setup> import { watch } from 'vue'; -import XUserList from '@/components/MkUserList.vue'; +import MkUserList from '@/components/MkUserList.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkTab from '@/components/MkTab.vue'; import * as os from '@/os'; diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index dd1685f272..2131188dde 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -8,22 +8,8 @@ <div v-else-if="tab === 'users'"> <XUsers/> </div> - <div v-else-if="tab === 'search'"> - <MkSpacer :content-max="1200"> - <div> - <MkInput v-model="searchQuery" :debounce="true" type="search"> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.searchUser }}</template> - </MkInput> - <MkRadios v-model="searchOrigin"> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkRadios> - </div> - - <XUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/> - </MkSpacer> + <div v-else-if="tab === 'roles'"> + <XRoles/> </div> </div> </MkStickyContainer> @@ -33,12 +19,10 @@ import { computed, watch } from 'vue'; import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; +import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkRadios from '@/components/MkRadios.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; -import XUserList from '@/components/MkUserList.vue'; const props = withDefaults(defineProps<{ tag?: string; @@ -49,22 +33,11 @@ const props = withDefaults(defineProps<{ let tab = $ref(props.initialTab); let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>(); -let searchQuery = $ref(null); -let searchOrigin = $ref('combined'); watch(() => props.tag, () => { if (tagsEl) tagsEl.toggleContent(props.tag == null); }); -const searchPagination = { - endpoint: 'users/search' as const, - limit: 10, - params: computed(() => (searchQuery && searchQuery !== '') ? { - query: searchQuery, - origin: searchOrigin, - } : null), -}; - const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ @@ -76,8 +49,9 @@ const headerTabs = $computed(() => [{ icon: 'ti ti-users', title: i18n.ts.users, }, { - key: 'search', - title: i18n.ts.search, + key: 'roles', + icon: 'ti ti-badges', + title: i18n.ts.roles, }]); definePageMetadata(computed(() => ({ diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 297286176b..165e357ebd 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -6,7 +6,7 @@ <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="note" class="note"> <div v-if="showNext" class="_margin"> - <XNotes class="" :pagination="nextPagination" :no-gap="true"/> + <MkNotes class="" :pagination="nextPagination" :no-gap="true"/> </div> <div class="main _margin"> @@ -29,7 +29,7 @@ </div> <div v-if="showPrev" class="_margin"> - <XNotes class="" :pagination="prevPagination" :no-gap="true"/> + <MkNotes class="" :pagination="prevPagination" :no-gap="true"/> </div> </div> <MkError v-else-if="error" @retry="fetchNote()"/> @@ -44,7 +44,7 @@ import { computed, watch } from 'vue'; import * as misskey from 'misskey-js'; import XNoteDetailed from '@/components/MkNoteDetailed.vue'; -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 7106951de2..da64a4c1e0 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -6,10 +6,10 @@ <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> </div> <div v-else-if="tab === 'mentions'"> - <XNotes :pagination="mentionsPagination"/> + <MkNotes :pagination="mentionsPagination"/> </div> <div v-else-if="tab === 'directNotes'"> - <XNotes :pagination="directNotesPagination"/> + <MkNotes :pagination="directNotesPagination"/> </div> </MkSpacer> </MkStickyContainer> @@ -17,12 +17,12 @@ <script lang="ts" setup> import { computed } from 'vue'; -import { notificationTypes } from 'misskey-js'; import XNotifications from '@/components/MkNotifications.vue'; -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { notificationTypes } from '@/const'; let tab = $ref('all'); let includeTypes = $ref<string[] | null>(null); diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue new file mode 100644 index 0000000000..2e9d3d6169 --- /dev/null +++ b/packages/frontend/src/pages/role.vue @@ -0,0 +1,47 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + + <MkSpacer :content-max="1200"> + <div class="_gaps_s"> + <div v-if="role">{{ role.description }}</div> + <MkUserList :pagination="users" :extractor="(item) => item.user"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import * as os from '@/os'; +import MkUserList from '@/components/MkUserList.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + role: string; +}>(); + +let role = $ref(); + +watch(() => props.role, () => { + os.api('roles/show', { + roleId: props.role, + }).then(res => { + role = res; + }); +}, { immediate: true }); + +const users = $computed(() => ({ + endpoint: 'roles/users' as const, + limit: 30, + params: { + roleId: props.role, + }, +})); + +definePageMetadata(computed(() => ({ + title: role?.name, + icon: 'ti ti-badge', +}))); +</script> + diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 7918f9f577..d32bdcd78e 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -2,18 +2,41 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <XNotes ref="notes" :pagination="pagination"/> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" :debounce="true" type="search" style="margin-bottom: var(--margin);" @update:model-value="search()"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkTab v-model="searchType" style="margin-bottom: var(--margin);" @update:model-value="search()"> + <option value="note">{{ i18n.ts.note }}</option> + <option value="user">{{ i18n.ts.user }}</option> + </MkTab> + + <div v-if="searchType === 'note'"> + <MkNotes v-if="searchQuery" ref="notes" :pagination="notePagination"/> + </div> + <div v-else> + <MkRadios v-model="searchOrigin" style="margin-bottom: var(--margin);" @update:model-value="search()"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkRadios> + + <MkUserList v-if="searchQuery" ref="users" :pagination="userPagination"/> + </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed } from 'vue'; -import XNotes from '@/components/MkNotes.vue'; +import { computed, onMounted } from 'vue'; +import MkNotes from '@/components/MkNotes.vue'; +import MkUserList from '@/components/MkUserList.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkRadios from '@/components/MkRadios.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import * as os from '@/os'; -import { useRouter } from '@/router'; +import { useRouter, mainRouter } from '@/router'; import { $i } from '@/account'; const router = useRouter(); @@ -21,14 +44,63 @@ const router = useRouter(); const props = defineProps<{ query: string; channel?: string; + type?: string; + origin?: string; }>(); -const query = props.query; +let searchQuery = $ref(''); +let searchType = $ref('note'); +let searchOrigin = $ref('combined'); + +onMounted(() => { + searchQuery = props.query ?? ''; + searchType = props.type ?? 'note'; + searchOrigin = props.origin ?? 'combined'; + + if (searchQuery) { + search(); + } +}); + +const search = async () => { + const query = searchQuery.toString().trim(); + + if (query == null || query === '') return; + + if (query.startsWith('@') && !query.includes(' ')) { + mainRouter.push(`/${query}`); + return; + } + + if (query.startsWith('#')) { + mainRouter.push(`/tags/${encodeURIComponent(query.substr(1))}`); + return; + } + + // like 2018/03/12 + if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(query.replace(/-/g, '/'))) { + const date = new Date(query.replace(/-/g, '/')); + + // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは + // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので + // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の + // 結果になってしまい、2018/03/12 のコンテンツは含まれない) + if (query.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { + date.setHours(23, 59, 59, 999); + } + + // TODO + //v.$root.$emit('warp', date); + os.alert({ + icon: 'ti ti-history', + iconOnly: true, autoClose: true, + }); + return; + } -if ($i != null) { - if (query.startsWith('https://') || (query.startsWith('@') && !query.includes(' '))) { + if (query.startsWith('https://')) { const promise = os.api('ap/show', { - uri: props.query, + uri: query, }); os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); @@ -36,28 +108,58 @@ if ($i != null) { const res = await promise; if (res.type === 'User') { - router.replace(`/@${res.object.username}@${res.object.host}`); + mainRouter.push(`/@${res.object.username}@${res.object.host}`); } else if (res.type === 'Note') { - router.replace(`/notes/${res.object.id}`); + mainRouter.push(`/notes/${res.object.id}`); } + + return; } -} -const pagination = { + if ($i != null) { + if (query.startsWith('https://') || (query.startsWith('@') && !query.includes(' '))) { + const promise = os.api('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + router.replace(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.replace(`/notes/${res.object.id}`); + } + } + } + + window.history.replaceState('', '', `/search?q=${encodeURIComponent(query)}&type=${searchType}${searchType === 'user' ? `&origin=${searchOrigin}` : ''}`); +}; + +const notePagination = { endpoint: 'notes/search' as const, limit: 10, params: computed(() => ({ - query: props.query, + query: searchQuery, channelId: props.channel, })), }; +const userPagination = { + endpoint: 'users/search' as const, + limit: 10, + params: computed(() => ({ + query: searchQuery, + origin: searchOrigin, + })), +}; const headerActions = $computed(() => []); const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ - title: i18n.t('searchWith', { q: props.query }), + title: searchQuery ? i18n.t('searchWith', { q: searchQuery }) : i18n.ts.search, icon: 'ti ti-search', }))); </script> diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue new file mode 100644 index 0000000000..1d836db5f5 --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -0,0 +1,82 @@ +<template> +<MkModal + ref="dialogEl" + :prefer-type="'dialog'" + :z-priority="'low'" + @click="cancel" + @close="cancel" + @closed="emit('closed')" +> + <div :class="$style.root" class="_gaps_m"> + <I18n :src="i18n.ts._2fa.step1" tag="div"> + <template #a> + <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> + </template> + <template #b> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> + </template> + </I18n> + <div> + {{ i18n.ts._2fa.step2 }}<br> + {{ i18n.ts._2fa.step2Click }} + </div> + <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a> + <MkKeyValue :copy="twoFactorData.url"> + <template #key>{{ i18n.ts._2fa.step2Url }}</template> + <template #value>{{ twoFactorData.url }}</template> + </MkKeyValue> + <div class="_buttons"> + <MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton> + <MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import MkButton from '@/components/MkButton.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import { i18n } from '@/i18n'; + +defineProps<{ + twoFactorData: { + qr: string; + url: string; + }; +}>(); + +const emit = defineEmits<{ + (ev: 'ok'): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const cancel = () => { + emit('cancel'); + emit('closed'); +}; + +const ok = () => { + emit('ok'); + emit('closed'); +}; +</script> + +<style lang="scss" module> +.root { + position: relative; + margin: auto; + padding: 32px; + min-width: 320px; + max-width: calc(100svw - 64px); + box-sizing: border-box; + background: var(--panel); + border-radius: var(--radius); +} + +.qr { + width: 20em; + max-width: 100%; +} +</style> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index e6ef09668c..891934d706 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -1,216 +1,258 @@ <template> -<div> - <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> - <template v-if="$i.twoFactorEnabled"> - <p>{{ i18n.ts._2fa.alreadyRegistered }}</p> - <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> +<FormSection :first="first"> + <template #label>{{ i18n.ts['2fa'] }}</template> - <template v-if="supportsCredentials"> - <hr class="totp-method-sep"> - - <h2 class="heading">{{ i18n.ts.securityKey }}</h2> - <p>{{ i18n.ts._2fa.securityKeyInfo }}</p> - <div class="key-list"> - <div v-for="key in $i.securityKeysList" class="key"> - <h3>{{ key.name }}</h3> - <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> - <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> - </div> + <div v-if="$i" class="_gaps_s"> + <MkFolder> + <template #icon><i class="ti ti-shield-lock"></i></template> + <template #label>{{ i18n.ts.totp }}</template> + <template #caption>{{ i18n.ts.totpDescription }}</template> + <div v-if="$i.twoFactorEnabled" class="_gaps_s"> + <div v-text="i18n.ts._2fa.alreadyRegistered"/> + <template v-if="$i.securityKeysList.length > 0"> + <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton> + <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo> + </template> + <MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> </div> - <MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> + <MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.securityKeyAndPasskey }}</template> + <div class="_gaps_s"> + <MkInfo> + {{ i18n.ts._2fa.securityKeyInfo }}<br> + <br> + {{ i18n.ts._2fa.chromePasskeyNotSupported }} + </MkInfo> - <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> - <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> + <MkInfo v-if="!supportsCredentials" warn> + {{ i18n.ts._2fa.securityKeyNotSupported }} + </MkInfo> - <ol v-if="registration && !registration.error"> - <li v-if="registration.stage >= 0"> - {{ i18n.ts.tapSecurityKey }} - <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> - </li> - <li v-if="registration.stage >= 1"> - <MkForm :disabled="registration.stage != 1 || registration.saving"> - <MkInput v-model="keyName" :max="30"> - <template #label>{{ i18n.ts.securityKeyName }}</template> - </MkInput> - <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> - <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> - </MkForm> - </li> - </ol> - </template> - </template> - <div v-if="twoFactorData && !$i.twoFactorEnabled"> - <ol style="margin: 0; padding: 0 0 0 1em;"> - <li> - <I18n :src="i18n.ts._2fa.step1" tag="span"> - <template #a> - <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> - </template> - <template #b> - <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> - </template> - </I18n> - </li> - <li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> - <li> - {{ i18n.ts._2fa.step3 }}<br> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> - <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> - </li> - </ol> - <MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo> + <MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn> + {{ i18n.ts._2fa.registerTOTPBeforeKey }} + </MkInfo> + + <template v-else> + <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> + <MkFolder v-for="key in $i.securityKeysList" :key="key.id"> + <template #label>{{ key.name }}</template> + <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> + <div class="_buttons"> + <MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton> + <MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton> + </div> + </MkFolder> + </template> + </div> + </MkFolder> + + <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)"> + <template #label>{{ i18n.ts.passwordLessLogin }}</template> + <template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template> + </MkSwitch> </div> -</div> +</FormSection> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, defineAsyncComponent } from 'vue'; import { hostname } from '@/config'; import { byteify, hexify, stringify } from '@/scripts/2fa'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 + +withDefaults(defineProps<{ + first?: boolean; +}>(), { + first: false, +}); + const twoFactorData = ref<any>(null); const supportsCredentials = ref(!!navigator.credentials); -const usePasswordLessLogin = ref($i!.usePasswordLessLogin); -const registration = ref<any>(null); -const keyName = ref(''); -const token = ref(null); +const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin); -function register() { - os.inputText({ - title: i18n.ts.password, +async function registerTOTP() { + const password = await os.inputText({ + title: i18n.ts._2fa.registerTOTP, + text: i18n.ts._2fa.passwordToTOTP, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - os.api('i/2fa/register', { - password: password, - }).then(data => { - twoFactorData.value = data; - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + const twoFactorData = await os.apiWithDialog('i/2fa/register', { + password: password.result, + }); + + const qrdialog = await new Promise<boolean>(res => { + os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), { + twoFactorData, + }, { + 'ok': () => res(true), + 'cancel': () => res(false), + }, 'closed'); + }); + if (!qrdialog) return; + + const token = await os.inputNumber({ + title: i18n.ts._2fa.step3Title, + text: i18n.ts._2fa.step3, + autocomplete: 'one-time-code', + }); + if (token.canceled) return; + + await os.apiWithDialog('i/2fa/done', { + token: token.result.toString(), + }); + + await os.alert({ + type: 'success', + text: i18n.ts._2fa.step4, }); } -function unregister() { +function unregisterTOTP() { os.inputText({ title: i18n.ts.password, type: 'password', + autocomplete: 'current-password', }).then(({ canceled, result: password }) => { if (canceled) return; - os.api('i/2fa/unregister', { + os.apiWithDialog('i/2fa/unregister', { password: password, - }).then(() => { - usePasswordLessLogin.value = false; - updatePasswordLessLogin(); - }).then(() => { - os.success(); - $i!.twoFactorEnabled = false; + }).catch(error => { + os.alert({ + type: 'error', + text: error, + }); }); }); } -function submit() { - os.api('i/2fa/done', { - token: token.value, - }).then(() => { - os.success(); - $i!.twoFactorEnabled = true; - }).catch(err => { - os.alert({ - type: 'error', - text: err, - }); +function renewTOTP() { + os.confirm({ + type: 'question', + title: i18n.ts._2fa.renewTOTP, + text: i18n.ts._2fa.renewTOTPConfirm, + okText: i18n.ts._2fa.renewTOTPOk, + cancelText: i18n.ts._2fa.renewTOTPCancel, + }).then(({ canceled }) => { + if (canceled) return; + registerTOTP(); }); } -function registerKey() { - registration.value.saving = true; - os.api('i/2fa/key-done', { - password: registration.value.password, - name: keyName.value, - challengeId: registration.value.challengeId, - // we convert each 16 bits to a string to serialise - clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), - attestationObject: hexify(registration.value.credential.response.attestationObject), - }).then(key => { - registration.value = null; - key.lastUsed = new Date(); - os.success(); +async function unregisterKey(key) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts._2fa.removeKey, + text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }), }); -} + if (confirm.canceled) return; -function unregisterKey(key) { - os.inputText({ + const password = await os.inputText({ title: i18n.ts.password, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - return os.api('i/2fa/remove-key', { - password, - credentialId: key.id, - }).then(() => { - usePasswordLessLogin.value = false; - updatePasswordLessLogin(); - }).then(() => { - os.success(); - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + await os.apiWithDialog('i/2fa/remove-key', { + password: password.result, + credentialId: key.id, }); + os.success(); } -function addSecurityKey() { - os.inputText({ +async function renameKey(key) { + const name = await os.inputText({ + title: i18n.ts.rename, + default: key.name, + type: 'text', + minLength: 1, + maxLength: 30, + }); + if (name.canceled) return; + + await os.apiWithDialog('i/2fa/update-key', { + name: name.result, + credentialId: key.id, + }); +} + +async function addSecurityKey() { + const password = await os.inputText({ title: i18n.ts.password, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - os.api('i/2fa/register-key', { - password, - }).then(reg => { - registration.value = { - password, - challengeId: reg!.challengeId, - stage: 0, - publicKeyOptions: { - challenge: byteify(reg!.challenge, 'base64'), - rp: { - id: hostname, - name: 'Misskey', - }, - user: { - id: byteify($i!.id, 'ascii'), - name: $i!.username, - displayName: $i!.name, - }, - pubKeyCredParams: [{ alg: -7, type: 'public-key' }], - timeout: 60000, - attestation: 'direct', - }, - saving: true, - }; - return navigator.credentials.create({ - publicKey: registration.value.publicKeyOptions, - }); - }).then(credential => { - registration.value.credential = credential; - registration.value.saving = false; - registration.value.stage = 1; - }).catch(err => { - console.warn('Error while registering?', err); - registration.value.error = err.message; - registration.value.stage = -1; - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + const challenge: any = await os.apiWithDialog('i/2fa/register-key', { + password: password.result, + }); + + const name = await os.inputText({ + title: i18n.ts._2fa.registerSecurityKey, + text: i18n.ts._2fa.securityKeyName, + type: 'text', + minLength: 1, + maxLength: 30, + }); + if (name.canceled) return; + + const webAuthnCreation = navigator.credentials.create({ + publicKey: { + challenge: byteify(challenge.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey', + }, + user: { + id: byteify($i!.id, 'ascii'), + name: $i!.username, + displayName: $i!.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct', + }, + }) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>; + + const credential = await os.promiseDialog( + webAuthnCreation, + null, + () => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない + i18n.ts._2fa.tapSecurityKey, + ); + if (!credential) return; + + await os.apiWithDialog('i/2fa/key-done', { + password: password.result, + name: name.result, + challengeId: challenge.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(credential.response.clientDataJSON), + attestationObject: hexify(credential.response.attestationObject), }); } -async function updatePasswordLessLogin() { - await os.api('i/2fa/password-less', { - value: !!usePasswordLessLogin.value, +async function updatePasswordLessLogin(value: boolean) { + await os.apiWithDialog('i/2fa/password-less', { + value, }); } </script> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index e6752460a8..a5619eab86 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -181,7 +181,7 @@ const menuDef = computed(() => [{ miLocalStorage.removeItem('theme'); miLocalStorage.removeItem('emojis'); miLocalStorage.removeItem('lastEmojisFetchedAt'); - await fetchCustomEmojis(); + await fetchCustomEmojis(true); unisonReload(); }, }, { diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index f64202fff2..2cf2f6d7f6 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -27,7 +27,6 @@ <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; -import { notificationTypes } from 'misskey-js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -36,6 +35,7 @@ import { $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; +import { notificationTypes } from '@/const'; let allowButton = $shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index b3459a2e2e..8b57dceefb 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -4,27 +4,29 @@ <FormSection> <template #label>{{ i18n.ts.manage }}</template> - <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;"> - <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> + <div class="_gaps_s"> + <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;"> + <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> - <MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> + <MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> - <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ plugin.author }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ plugin.description }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.permission }}</template> - <template #value>{{ plugin.permission }}</template> - </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.permission }}</template> + <template #value>{{ plugin.permission }}</template> + </MkKeyValue> - <div class="_buttons"> - <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> - <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + <div class="_buttons"> + <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + </div> </div> </div> </FormSection> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 5692ce80cb..c83c48d5ad 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -46,7 +46,7 @@ <option value="followers">{{ i18n.ts._visibility.followers }}</option> <option value="specified">{{ i18n.ts._visibility.specified }}</option> </MkSelect> - <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.localOnly }}</MkSwitch> + <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> </div> </MkFolder> </div> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index a492a82588..41563c441f 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -124,11 +124,11 @@ function saveFields() { function save() { os.apiWithDialog('i/update', { - name: profile.name || null, - description: profile.description || null, - location: profile.location || null, - birthday: profile.birthday || null, - lang: profile.lang || null, + name: profile.name ?? null, + description: profile.description ?? null, + location: profile.location ?? null, + birthday: profile.birthday ?? null, + lang: profile.lang ?? null, isBot: !!profile.isBot, isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index b09c4ffd2f..0cc2df09c5 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -5,11 +5,8 @@ <MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> </FormSection> - <FormSection> - <template #label>{{ i18n.ts.twoStepAuthentication }}</template> - <X2fa/> - </FormSection> - + <X2fa/> + <FormSection> <template #label>{{ i18n.ts.signinHistory }}</template> <MkPagination :pagination="pagination" disable-auto-load> @@ -56,18 +53,21 @@ async function change() { const { canceled: canceled1, result: currentPassword } = await os.inputText({ title: i18n.ts.currentPassword, type: 'password', + autocomplete: 'current-password', }); if (canceled1) return; const { canceled: canceled2, result: newPassword } = await os.inputText({ title: i18n.ts.newPassword, type: 'password', + autocomplete: 'new-password', }); if (canceled2) return; const { canceled: canceled3, result: newPassword2 } = await os.inputText({ title: i18n.ts.newPasswordRetype, type: 'password', + autocomplete: 'new-password', }); if (canceled3) return; @@ -109,7 +109,7 @@ definePageMetadata({ <style lang="scss" scoped> .timnmucd { - padding: 16px; + padding: 12px; &:first-child { border-top-left-radius: 6px; diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index 5d6d01d2ae..511052c424 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -2,14 +2,14 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <XNotes class="" :pagination="pagination"/> + <MkNotes class="" :pagination="pagination"/> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> import { computed } from 'vue'; -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index f9ad609f56..d982a76d03 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -8,7 +8,7 @@ <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div :class="$style.tl"> - <XTimeline + <MkTimeline ref="tlComponent" :key="src" :src="src" @@ -24,7 +24,7 @@ <script lang="ts" setup> import { defineAsyncComponent, computed, watch, provide } from 'vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; @@ -44,7 +44,7 @@ const keymap = { 't': focus, }; -const tlComponent = $shallowRef<InstanceType<typeof XTimeline>>(); +const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>(); const rootEl = $shallowRef<HTMLElement>(); let queue = $ref(0); diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index b8ed5787cf..13a06286f6 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -112,7 +112,7 @@ <MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> - <MkRolePreview :class="$style.role" :role="role"/> + <MkRolePreview :class="$style.role" :role="role" :for-moderation="true"/> <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> </div> diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 2bfda12a6e..acf7ea9b2c 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -4,7 +4,7 @@ <div ref="rootEl" class="eqqrhokj"> <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div class="tl"> - <XTimeline + <MkTimeline ref="tlEl" :key="listId" class="tl" src="list" @@ -19,7 +19,7 @@ <script lang="ts" setup> import { computed, watch } from 'vue'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; import { useRouter } from '@/router'; @@ -34,7 +34,7 @@ const props = defineProps<{ let list = $ref(null); let queue = $ref(0); -let tlEl = $shallowRef<InstanceType<typeof XTimeline>>(); +let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); let rootEl = $shallowRef<HTMLElement>(); watch(() => props.listId, async () => { diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue new file mode 100644 index 0000000000..fac7593e9c --- /dev/null +++ b/packages/frontend/src/pages/user-tag.vue @@ -0,0 +1,38 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + + <MkSpacer :content-max="1200"> + <div class="_gaps_s"> + <MkUserList :pagination="tagUsers"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import * as os from '@/os'; +import MkUserList from '@/components/MkUserList.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + tag: string; +}>(); + +const tagUsers = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: props.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +definePageMetadata(computed(() => ({ + title: props.tag, + icon: 'ti ti-user-search', +}))); +</script> + diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 66c754c452..441b19440c 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -100,7 +100,7 @@ <XPhotos :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/> </template> - <XNotes :class="$style.tl" :no-gap="true" :pagination="pagination"/> + <MkNotes :class="$style.tl" :no-gap="true" :pagination="pagination"/> </div> </div> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> @@ -130,7 +130,7 @@ import { i18n } from '@/i18n'; import { $i } from '@/account'; import { dateString } from '@/filters/date'; import { confetti } from '@/scripts/confetti'; -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index aae55c5f1c..d8fc253910 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -8,7 +8,7 @@ <option value="files">{{ i18n.ts.withFiles }}</option> </MkTab> </template> - <XNotes :no-gap="true" :pagination="pagination" :class="$style.tl"/> + <MkNotes :no-gap="true" :pagination="pagination" :class="$style.tl"/> </MkStickyContainer> </MkSpacer> </template> @@ -16,7 +16,7 @@ <script lang="ts" setup> import { ref, computed } from 'vue'; import * as misskey from 'misskey-js'; -import XNotes from '@/components/MkNotes.vue'; +import MkNotes from '@/components/MkNotes.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n'; diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 05c55fdf31..f62a6461c5 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -1,18 +1,18 @@ <template> <div v-if="meta" class="rsqzvsbo"> - <div class="top"> - <MkFeaturedPhotos class="bg"/> - <XTimeline class="tl"/> - <div class="shape1"></div> - <div class="shape2"></div> - <img src="/client-assets/misskey.svg" class="misskey"/> - <div class="emojis"> - <MkEmoji :normal="true" :no-style="true" emoji="👍"/> - <MkEmoji :normal="true" :no-style="true" emoji="❤"/> - <MkEmoji :normal="true" :no-style="true" emoji="😆"/> - <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> - <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> - </div> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape1"></div> + <div class="shape2"></div> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="👍"/> + <MkEmoji :normal="true" :no-style="true" emoji="❤"/> + <MkEmoji :normal="true" :no-style="true" emoji="😆"/> + <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> + <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> + </div> + <div class="contents"> <div class="main"> <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> @@ -26,37 +26,46 @@ <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> </div> - <div class="action"> - <MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton> - <MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> + <div class="action _gaps_s"> + <MkButton full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton> + <MkButton full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton> + <MkButton full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> </div> </div> </div> - <div v-if="instances && instances.length > 0" class="federation"> - <MarqueeText :duration="40"> - <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> - <!--<MkInstanceCardMini :instance="instance"/>--> - <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> - <span class="name _monospace">{{ instance.host }}</span> - </MkA> - </MarqueeText> + <div v-if="instance.policies.ltlAvailable" class="tl"> + <div class="title">{{ i18n.ts.letsLookAtTimeline }}</div> + <div class="body"> + <MkTimeline src="local"/> + </div> </div> </div> + <div v-if="instances && instances.length > 0" class="federation"> + <MarqueeText :duration="40"> + <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> + <!--<MkInstanceCardMini :instance="instance"/>--> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <span class="name _monospace">{{ instance.host }}</span> + </MkA> + </MarqueeText> + </div> </div> </template> <script lang="ts" setup> import { } from 'vue'; +import { Instance } from 'misskey-js/built/entities'; import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import { instanceName } from '@/config'; import * as os from '@/os'; import { i18n } from '@/i18n'; -import { Instance } from 'misskey-js/built/entities'; +import { instance } from '@/instance'; let meta = $ref<Instance>(); let instances = $ref<any[]>(); @@ -105,100 +114,103 @@ function showMenu(ev) { }, }], ev.currentTarget ?? ev.target); } + +function exploreOtherServers() { + // TODO: 言語をよしなに + window.open('https://join.misskey.page/ja-JP/instances', '_blank'); +} </script> <style lang="scss" scoped> .rsqzvsbo { - > .top { - display: flex; - text-align: center; - min-height: 100vh; - box-sizing: border-box; - padding: 16px; + > .bg { + position: fixed; + top: 0; + right: 0; + width: 80vw; // 100%からshapeの幅を引いている + height: 100vh; + } - > .bg { - position: absolute; - top: 0; - right: 0; - width: 80%; // 100%からshapeの幅を引いている - height: 100%; + > .tl { + position: fixed; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + padding: 128px 0; + width: 500px; + height: calc(100% - 256px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + + @media (max-width: 1200px) { + display: none; } + } - > .tl { - position: absolute; - top: 0; - bottom: 0; - right: 64px; - margin: auto; - padding: 128px 0; - width: 500px; - height: calc(100% - 256px); - overflow: hidden; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + > .shape1 { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--accent); + clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); + } + > .shape2 { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--accent); + clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); + opacity: 0.5; + } - @media (max-width: 1200px) { - display: none; - } - } + > .misskey { + position: fixed; + top: 42px; + left: 42px; + width: 140px; - > .shape1 { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--accent); - clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); - } - > .shape2 { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--accent); - clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); - opacity: 0.5; + @media (max-width: 450px) { + width: 130px; } + } - > .misskey { - position: absolute; - top: 42px; - left: 42px; - width: 140px; + > .emojis { + position: fixed; + bottom: 32px; + left: 35px; - @media (max-width: 450px) { - width: 130px; - } + > * { + margin-right: 8px; } - > .emojis { - position: absolute; - bottom: 32px; - left: 35px; + @media (max-width: 1200px) { + display: none; + } + } - > * { - margin-right: 8px; - } + > .contents { + position: relative; + width: min(430px, calc(100% - 32px)); + margin-left: 128px; + padding: 150px 0 100px 0; - @media (max-width: 1200px) { - display: none; - } + @media (max-width: 1200px) { + margin: auto; } > .main { position: relative; - width: min(480px, 100%); - margin: auto auto auto 128px; background: var(--panel); border-radius: var(--radius); box-shadow: 0 12px 32px rgb(0 0 0 / 25%); - - @media (max-width: 1200px) { - margin: auto; - } - + text-align: center; + > .icon { width: 85px; margin-top: -47px; @@ -247,25 +259,44 @@ function showMenu(ev) { } } - > .federation { - position: absolute; - bottom: 16px; - left: 0; - right: 0; - margin: auto; - background: var(--acrylicPanel); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - border-radius: 999px; + > .tl { + position: relative; + background: var(--panel); + border-radius: var(--radius); overflow: clip; - width: 800px; - padding: 8px 0; + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + margin-top: 16px; + + > .title { + padding: 12px 16px; + border-bottom: solid 1px var(--divider); + } - @media (max-width: 900px) { - display: none; + > .body { + height: 350px; + overflow: auto; } } } + + > .federation { + position: fixed; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-radius: 999px; + overflow: clip; + width: 800px; + padding: 8px 0; + + @media (max-width: 900px) { + display: none; + } + } } </style> diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index 2ca89b7351..2616a8a1d5 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -48,8 +48,8 @@ export class Storage<T extends StateDef> { // 簡易的にキューイングして占有ロックとする private currentIdbJob: Promise<any> = Promise.resolve(); private addIdbSetJob<T>(job: () => Promise<T>) { - const promise = this.currentIdbJob.then(job, e => { - console.error('Pizzax failed to save data to idb!', e); + const promise = this.currentIdbJob.then(job, err => { + console.error('Pizzax failed to save data to idb!', err); return job(); }); this.currentIdbJob = promise; @@ -130,22 +130,22 @@ export class Storage<T extends StateDef> { await defaultStore.ready; api('i/registry/get-all', { scope: ['client', this.key] }) - .then(kvs => { - const cache: Partial<T> = {}; - for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { - if (v.where === 'account') { - if (Object.prototype.hasOwnProperty.call(kvs, k)) { - this.reactiveState[k].value = this.state[k] = (kvs as Partial<T>)[k]; - cache[k] = (kvs as Partial<T>)[k]; - } else { - this.reactiveState[k].value = this.state[k] = v.default; + .then(kvs => { + const cache: Partial<T> = {}; + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + this.reactiveState[k].value = this.state[k] = (kvs as Partial<T>)[k]; + cache[k] = (kvs as Partial<T>)[k]; + } else { + this.reactiveState[k].value = this.state[k] = v.default; + } } } - } - return set(this.registryCacheKeyName, cache); - }) - .then(() => resolve()); + return set(this.registryCacheKeyName, cache); + }) + .then(() => resolve()); }, 1); } else { resolve(); diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index ff380df639..70576688b1 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -198,8 +198,11 @@ export const routes = [{ component: page(() => import('./pages/theme-editor.vue')), loginRequired: true, }, { - path: '/explore/tags/:tag', - component: page(() => import('./pages/explore.vue')), + path: '/roles/:role', + component: page(() => import('./pages/role.vue')), +}, { + path: '/user-tags/:tag', + component: page(() => import('./pages/user-tag.vue')), }, { path: '/explore', component: page(() => import('./pages/explore.vue')), @@ -210,6 +213,8 @@ export const routes = [{ query: { q: 'query', channel: 'channel', + type: 'type', + origin: 'origin', }, }, { path: '/authorize-follow', diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 48b740f4c3..9c0ff3d1b2 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -9,6 +9,7 @@ import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; import { noteActions } from '@/store'; import { miLocalStorage } from '@/local-storage'; +import { getUserMenu } from '@/scripts/get-user-menu'; export function getNoteMenu(props: { note: misskey.entities.Note; @@ -99,66 +100,6 @@ export function getNoteMenu(props: { }); } - async function clip(): Promise<void> { - const clips = await os.api('clips/list'); - os.popupMenu([{ - icon: 'ti ti-plus', - text: i18n.ts.createNew, - action: async () => { - const { canceled, result } = await os.form(i18n.ts.createNewClip, { - name: { - type: 'string', - label: i18n.ts.name, - }, - description: { - type: 'string', - required: false, - multiline: true, - label: i18n.ts.description, - }, - isPublic: { - type: 'boolean', - label: i18n.ts.public, - default: false, - }, - }); - if (canceled) return; - - const clip = await os.apiWithDialog('clips/create', result); - - claimAchievement('noteClipped1'); - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); - }, - }, null, ...clips.map(clip => ({ - text: clip.name, - action: () => { - claimAchievement('noteClipped1'); - os.promiseDialog( - os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), - null, - async (err) => { - if (err.id === '734806c4-542c-463a-9311-15c512803965') { - const confirm = await os.confirm({ - type: 'warning', - text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), - }); - if (!confirm.canceled) { - os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); - if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; - } - } else { - os.alert({ - type: 'error', - text: err.message + '\n' + err.id, - }); - } - }, - ); - }, - }))], props.menuButton.value, { - }).then(focus); - } - async function unclip(): Promise<void> { os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); props.isDeleted.value = true; @@ -200,7 +141,7 @@ export function getNoteMenu(props: { props.translating.value = true; const res = await os.api('notes/translate', { noteId: appearNote.id, - targetLang: miLocalStorage.getItem('lang') || navigator.language, + targetLang: miLocalStorage.getItem('lang') ?? navigator.language, }); props.translating.value = false; props.translation.value = res; @@ -240,7 +181,7 @@ export function getNoteMenu(props: { icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { - window.open(appearNote.url || appearNote.uri, '_blank'); + window.open(appearNote.url ?? appearNote.uri, '_blank'); }, } : undefined, { @@ -264,9 +205,67 @@ export function getNoteMenu(props: { action: () => toggleFavorite(true), }), { + type: 'parent', icon: 'ti ti-paperclip', text: i18n.ts.clip, - action: () => clip(), + children: async () => { + const clips = await os.api('clips/list'); + return [{ + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + claimAchievement('noteClipped1'); + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + claimAchievement('noteClipped1'); + os.promiseDialog( + os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, + }))]; + }, }, statePromise.then(state => state.isMutedThread ? { icon: 'ti ti-message-off', @@ -286,6 +285,15 @@ export function getNoteMenu(props: { text: i18n.ts.pin, action: () => togglePin(true), } : undefined, + appearNote.userId !== $i.id ? { + type: 'parent', + icon: 'ti ti-user', + text: i18n.ts.user, + children: async () => { + const user = await os.api('users/show', { userId: appearNote.userId }); + return getUserMenu(user); + }, + } : undefined, /* ...($i.isModerator || $i.isAdmin ? [ null, @@ -302,7 +310,7 @@ export function getNoteMenu(props: { icon: 'ti ti-exclamation-circle', text: i18n.ts.reportAbuse, action: () => { - const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + const u = appearNote.url ?? appearNote.uri ?? `${url}/notes/${appearNote.id}`; os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { user: appearNote.user, initialComment: `Note: ${u}\n-----\n`, @@ -344,7 +352,7 @@ export function getNoteMenu(props: { icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { - window.open(appearNote.url || appearNote.uri, '_blank'); + window.open(appearNote.url ?? appearNote.uri, '_blank'); }, } : undefined] .filter(x => x !== undefined); diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 557b257f62..69d0ed085d 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -1,4 +1,5 @@ import { defineAsyncComponent } from 'vue'; +import * as misskey from 'misskey-js'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { host } from '@/config'; @@ -8,7 +9,7 @@ import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; import { Router } from '@/nirax'; -export function getUserMenu(user, router: Router = mainRouter) { +export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; async function pushList() { @@ -102,6 +103,8 @@ export function getUserMenu(user, router: Router = mainRouter) { } async function invalidateFollow() { + if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return; + os.apiWithDialog('following/invalidate', { userId: user.id, }).then(() => { @@ -113,7 +116,7 @@ export function getUserMenu(user, router: Router = mainRouter) { icon: 'ti ti-at', text: i18n.ts.copyUsername, action: () => { - copyToClipboard(`@${user.username}@${user.host || host}`); + copyToClipboard(`@${user.username}@${user.host ?? host}`); }, }, { icon: 'ti ti-info-circle', @@ -166,12 +169,6 @@ export function getUserMenu(user, router: Router = mainRouter) { if (iAmModerator) { menu = menu.concat([null, { - icon: 'ti ti-user-exclamation', - text: i18n.ts.moderation, - action: () => { - router.push('/user-info/' + user.id + '#moderation'); - }, - }, { icon: 'ti ti-badges', text: i18n.ts.roles, action: async () => { diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts index d499ea0203..4daf203e06 100644 --- a/packages/frontend/src/scripts/get-user-name.ts +++ b/packages/frontend/src/scripts/get-user-name.ts @@ -1,3 +1,3 @@ export default function(user: { name?: string | null, username: string }): string { - return user.name || user.username; + return user.name === '' ? user.username : user.name ?? user.username; } diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts index 5c07a08315..587c6a36c8 100644 --- a/packages/frontend/src/scripts/hpml/index.ts +++ b/packages/frontend/src/scripts/hpml/index.ts @@ -58,7 +58,7 @@ export class HpmlScope { constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { this.layerdStates = layerdStates; - this.name = name || 'anonymous'; + this.name = name ?? 'anonymous'; } @autobind diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts index 24c9ed8bcb..692826fc90 100644 --- a/packages/frontend/src/scripts/hpml/type-checker.ts +++ b/packages/frontend/src/scripts/hpml/type-checker.ts @@ -63,7 +63,7 @@ export class HpmlTypeChecker { @autobind public getExpectedType(v: Expr, slot: number): Type { - const def = funcDefs[v.type || '']; + const def = funcDefs[v.type ?? '']; if (def == null) { throw new Error('Unknown type: ' + v.type); } @@ -107,7 +107,7 @@ export class HpmlTypeChecker { return pageVar.type; } - const envVar = envVarsDef[v.value || '']; + const envVar = envVarsDef[v.value ?? '']; if (envVar !== undefined) { return envVar; } diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts index e3d9dc00c2..a002f02b5a 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend/src/scripts/scroll.ts @@ -10,7 +10,7 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { } } -export function getStickyTop(el: HTMLElement, container: HTMLElement | null = null, top: number = 0) { +export function getStickyTop(el: HTMLElement, container: HTMLElement | null = null, top = 0) { if (!el.parentElement) return top; const data = el.dataset.stickyContainerHeaderHeight; const newTop = data ? Number(data) + top : top; @@ -23,14 +23,14 @@ export function getScrollPosition(el: HTMLElement | null): number { return container == null ? window.scrollY : container.scrollTop; } -export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) { +export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) { // とりあえず評価してみる if (isTopVisible(el)) { cb(); if (once) return null; } - const container = getScrollContainer(el) || window; + const container = getScrollContainer(el) ?? window; const onScroll = ev => { if (!document.body.contains(el)) return; @@ -45,7 +45,7 @@ export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance: numbe return removeListener; } -export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) { +export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) { const container = getScrollContainer(el); // とりあえず評価してみる @@ -54,7 +54,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance: nu if (once) return null; } - const containerOrWindow = container || window; + const containerOrWindow = container ?? window; const onScroll = ev => { if (!document.body.contains(el)) return; if (isBottomVisible(el, 1, container)) { @@ -104,12 +104,12 @@ export function scrollToBottom( } else { window.scroll({ top: (el.scrollHeight - window.innerHeight + getStickyTop(el, container) + (window.innerWidth <= 500 ? 96 : 0)) || 0, - ...options + ...options, }); } } -export function isTopVisible(el: HTMLElement, tolerance: number = 1): boolean { +export function isTopVisible(el: HTMLElement, tolerance = 1): boolean { const scrollTop = getScrollPosition(el); return scrollTop <= tolerance; } @@ -124,6 +124,6 @@ export function getBodyScrollHeight() { return Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, - document.body.clientHeight, document.documentElement.clientHeight + document.body.clientHeight, document.documentElement.clientHeight, ); } diff --git a/packages/frontend/src/scripts/search.ts b/packages/frontend/src/scripts/search.ts deleted file mode 100644 index 69f1586b77..0000000000 --- a/packages/frontend/src/scripts/search.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as os from '@/os'; -import { i18n } from '@/i18n'; -import { mainRouter } from '@/router'; - -export async function search() { - const { canceled, result: query } = await os.inputText({ - title: i18n.ts.search, - }); - if (canceled || query == null || query === '') return; - - const q = query.trim(); - - if (q.startsWith('@') && !q.includes(' ')) { - mainRouter.push(`/${q}`); - return; - } - - if (q.startsWith('#')) { - mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`); - return; - } - - // like 2018/03/12 - if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) { - const date = new Date(q.replace(/-/g, '/')); - - // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは - // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので - // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の - // 結果になってしまい、2018/03/12 のコンテンツは含まれない) - if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { - date.setHours(23, 59, 59, 999); - } - - // TODO - //v.$root.$emit('warp', date); - os.alert({ - icon: 'ti ti-history', - iconOnly: true, autoClose: true, - }); - return; - } - - if (q.startsWith('https://')) { - const promise = os.api('ap/show', { - uri: q, - }); - - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); - - const res = await promise; - - if (res.type === 'User') { - mainRouter.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - mainRouter.push(`/notes/${res.object.id}`); - } - - return; - } - - mainRouter.push(`/search?q=${encodeURIComponent(q)}`); -} diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend/src/scripts/use-document-visibility.ts new file mode 100644 index 0000000000..47e91dd937 --- /dev/null +++ b/packages/frontend/src/scripts/use-document-visibility.ts @@ -0,0 +1,19 @@ +import { onMounted, onUnmounted, ref, Ref } from 'vue'; + +export function useDocumentVisibility(): Ref<DocumentVisibilityState> { + const visibility = ref(document.visibilityState); + + const onChange = (): void => { + visibility.value = document.visibilityState; + }; + + onMounted(() => { + document.addEventListener('visibilitychange', onChange); + }); + + onUnmounted(() => { + document.removeEventListener('visibilitychange', onChange); + }); + + return visibility; +} diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts index aa1244665b..580c7da007 100644 --- a/packages/frontend/src/theme-store.ts +++ b/packages/frontend/src/theme-store.ts @@ -1,13 +1,13 @@ -import { api } from '@/os'; -import { $i } from '@/account'; import { Theme } from './scripts/theme'; import { miLocalStorage } from './local-storage'; +import { api } from '@/os'; +import { $i } from '@/account'; const lsCacheKey = $i ? `themes:${$i.id}` as const : null; export function getThemes(): Theme[] { if ($i == null) return []; - return JSON.parse(miLocalStorage.getItem(lsCacheKey!) || '[]'); + return JSON.parse(miLocalStorage.getItem(lsCacheKey!) ?? '[]'); } export async function fetchThemes(): Promise<void> { diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index 34ddfa1d32..3dfb371d32 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -45,11 +45,11 @@ import { defineAsyncComponent, defineComponent } from 'vue'; import { openInstanceMenu } from './_common_/common'; import { host } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; import { openAccountMenu } from '@/account'; import MkButton from '@/components/MkButton.vue'; +import { mainRouter } from '@/router'; export default defineComponent({ components: { @@ -103,7 +103,7 @@ export default defineComponent({ }, search() { - search(); + mainRouter.push('/search'); }, more(ev) { diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue index a11c2ba10e..6fff233ac5 100644 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -44,12 +44,12 @@ import { defineAsyncComponent, defineComponent } from 'vue'; import { openInstanceMenu } from './_common_/common'; import { host } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; import { openAccountMenu } from '@/account'; import MkButton from '@/components/MkButton.vue'; import { StickySidebar } from '@/scripts/sticky-sidebar'; +import { mainRouter } from '@/router'; //import MisskeyLogo from '@assets/client/misskey.svg'; export default defineComponent({ @@ -120,7 +120,7 @@ export default defineComponent({ }, search() { - search(); + mainRouter.push('/search'); }, more(ev) { diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index 92997ffb66..a359463d9b 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -125,7 +125,7 @@ function onAiClick(ev) { if (window.innerWidth < 1024) { const currentUI = miLocalStorage.getItem('ui'); - miLocalStorage.setItem('ui_temp', currentUI || 'default'); + miLocalStorage.setItem('ui_temp', currentUI ?? 'default'); miLocalStorage.setItem('ui', 'default'); location.reload(); } diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index ef29b2e72f..be168b4282 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -4,7 +4,7 @@ <div :class="$style.main"> <XStatusBars/> - <div ref="columnsEl" :class="[$style.columns, deckStore.reactiveState.columnAlign.value]" @contextmenu.self.prevent="onContextmenu"> + <div ref="columnsEl" :class="[$style.columns, deckStore.reactiveState.columnAlign.value, { [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu"> <template v-for="ids in layout"> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> <section @@ -98,6 +98,7 @@ import { $i } from '@/account'; import { i18n } from '@/i18n'; import { mainRouter } from '@/router'; import { unisonReload } from '@/scripts/unison-reload'; +import { deviceKind } from '@/scripts/device-kind'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); mainRouter.navHook = (path, flag): boolean => { @@ -115,6 +116,7 @@ window.addEventListener('resize', () => { isMobile.value = window.innerWidth <= 500; }); +const snapScroll = deviceKind === 'smartphone' || deviceKind === 'tablet'; const drawerMenuShowing = ref(false); const route = 'TODO'; @@ -297,9 +299,14 @@ async function deleteProfile() { margin-right: auto; } } + + &.snapScroll { + scroll-snap-type: x mandatory; + } } .column { + scroll-snap-align: start; flex-shrink: 0; border-right: solid var(--deckDividerThickness) var(--deckDivider); diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 4c69c8e8e8..76a8b6e760 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -4,7 +4,7 @@ <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/> + <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/> </XColumn> </template> @@ -12,7 +12,7 @@ import { onMounted } from 'vue'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -26,7 +26,7 @@ const emit = defineEmits<{ (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; }>(); -let timeline = $shallowRef<InstanceType<typeof XTimeline>>(); +let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); onMounted(() => { if (props.column.antennaId == null) { diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 169d2c4056..4c6b41e42e 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -8,7 +8,7 @@ <div style="padding: 8px; text-align: center;"> <MkButton primary gradate rounded inline @click="post"><i class="ti ti-pencil"></i></MkButton> </div> - <XTimeline ref="timeline" src="channel" :channel="column.channelId" @after="() => emit('loaded')"/> + <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @after="() => emit('loaded')"/> </template> </XColumn> </template> @@ -17,7 +17,7 @@ import { } from 'vue'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -32,7 +32,7 @@ const emit = defineEmits<{ (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; }>(); -let timeline = $shallowRef<InstanceType<typeof XTimeline>>(); +let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); if (props.column.channelId == null) { setChannel(); diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index 75b018cacd..15b76c4d92 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -2,15 +2,15 @@ <XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template> - <XNotes :pagination="pagination"/> + <MkNotes :pagination="pagination"/> </XColumn> </template> <script lang="ts" setup> import { } from 'vue'; import XColumn from './column.vue'; -import XNotes from '@/components/MkNotes.vue'; import { Column } from './deck-store'; +import MkNotes from '@/components/MkNotes.vue'; defineProps<{ column: Column; diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 58633e5672..352c1d246a 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -4,7 +4,7 @@ <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/> + <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/> </XColumn> </template> @@ -12,7 +12,7 @@ import { } from 'vue'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -26,7 +26,7 @@ const emit = defineEmits<{ (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; }>(); -let timeline = $shallowRef<InstanceType<typeof XTimeline>>(); +let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); if (props.column.listId == null) { setList(); diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index 16962956a0..852d7a8f7e 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -2,15 +2,15 @@ <XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template> - <XNotes :pagination="pagination"/> + <MkNotes :pagination="pagination"/> </XColumn> </template> <script lang="ts" setup> import { } from 'vue'; import XColumn from './column.vue'; -import XNotes from '@/components/MkNotes.vue'; import { Column } from './deck-store'; +import MkNotes from '@/components/MkNotes.vue'; defineProps<{ column: Column; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 8b1b920fa8..a947e27e57 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -15,7 +15,7 @@ </p> <p :class="$style.disabledDescription">{{ $t('disabled-timeline.description') }}</p> </div> - <XTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')"/> + <MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')"/> </XColumn> </template> @@ -23,7 +23,7 @@ import { onMounted } from 'vue'; import XColumn from './column.vue'; import { removeColumn, updateColumn, Column } from './deck-store'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { i18n } from '@/i18n'; diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue index 9494b1b705..023b7fdb94 100644 --- a/packages/frontend/src/ui/visitor/a.vue +++ b/packages/frontend/src/ui/visitor/a.vue @@ -40,7 +40,6 @@ import { defineComponent } from 'vue'; import XHeader from './header.vue'; import { host, instanceName } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import MkButton from '@/components/MkButton.vue'; import { ColdDeviceStorage } from '@/store'; @@ -77,7 +76,9 @@ export default defineComponent({ if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; this.$store.set('darkMode', !this.$store.state.darkMode); }, - 's': search, + 's': () => { + mainRouter.push('/search'); + }, 'h|/': this.help, }; }, diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue index e36f36f153..e2168768e8 100644 --- a/packages/frontend/src/ui/visitor/b.vue +++ b/packages/frontend/src/ui/visitor/b.vue @@ -11,7 +11,10 @@ <div class="contents"> <XHeader v-if="!root" class="header"/> - <main style="container-type: inline-size;"> + <main v-if="!root" style="container-type: inline-size;"> + <RouterView/> + </main> + <main v-else> <RouterView/> </main> <div v-if="!root" class="powered-by"> @@ -55,7 +58,6 @@ import { ComputedRef, onMounted, provide } from 'vue'; import XHeader from './header.vue'; import XKanban from './kanban.vue'; import { host, instanceName } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import { instance } from '@/instance'; import XSigninDialog from '@/components/MkSigninDialog.vue'; @@ -94,7 +96,9 @@ const keymap = $computed(() => { if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; defaultStore.set('darkMode', !defaultStore.state.darkMode); }, - 's': search, + 's': () => { + mainRouter.push('/search'); + }, }; }); diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue index 2647d0e62a..aaa7e77e90 100644 --- a/packages/frontend/src/ui/visitor/header.vue +++ b/packages/frontend/src/ui/visitor/header.vue @@ -27,7 +27,7 @@ import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import * as os from '@/os'; import { instance } from '@/instance'; -import { search } from '@/scripts/search'; +import { mainRouter } from '@/router'; export default defineComponent({ data() { @@ -55,7 +55,9 @@ export default defineComponent({ }, {}, 'closed'); }, - search, + search() { + mainRouter.push('/search'); + }, }, }); </script> diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index a096cc8fe8..7949fc4a93 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -1,7 +1,7 @@ <template> <div class="mkw-onlineUsers data-cy-mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }"> <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text"> - <template #n><b>{{ onlineUsersCount }}</b></template> + <template #n><b>{{ number(onlineUsersCount) }}</b></template> </I18n> </div> </template> @@ -13,6 +13,7 @@ import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; +import number from '@/filters/number'; const name = 'onlineUsers'; diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index fde7048b00..d6be6532a6 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -16,7 +16,7 @@ </template> <div> - <XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> + <MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> </div> </MkContainer> </template> @@ -27,7 +27,7 @@ import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import MkContainer from '@/components/MkContainer.vue'; -import XTimeline from '@/components/MkTimeline.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; import { i18n } from '@/i18n'; const name = 'timeline'; diff --git a/packages/shared/.eslintrc.js b/packages/shared/.eslintrc.js index 6d38a9fb9f..7c979a93dc 100644 --- a/packages/shared/.eslintrc.js +++ b/packages/shared/.eslintrc.js @@ -77,6 +77,17 @@ module.exports = { '@typescript-eslint/prefer-nullish-coalescing': [ 'error', ], + '@typescript-eslint/naming-convention': [ + 'error', + { + "selector": "typeLike", + "format": ["PascalCase"] + }, + { + "selector": "typeParameter", + "format": [] + } + ], 'import/no-unresolved': ['off'], 'import/no-default-export': ['warn'], 'import/order': ['warn', { diff --git a/packages/sw/package.json b/packages/sw/package.json index 4de260fcfc..7fab90a243 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -14,9 +14,9 @@ "misskey-js": "0.0.15" }, "devDependencies": { - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/parser": "5.52.0", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.62", - "eslint": "8.33.0", + "eslint": "8.34.0", "eslint-plugin-import": "2.27.5", "typescript": "4.9.5" } diff --git a/packages/sw/src/scripts/get-user-name.ts b/packages/sw/src/scripts/get-user-name.ts index ccc38c298e..4daf203e06 100644 --- a/packages/sw/src/scripts/get-user-name.ts +++ b/packages/sw/src/scripts/get-user-name.ts @@ -1,7 +1,3 @@ export default function(user: { name?: string | null, username: string }): string { - // Show username if name is empty. - // XXX: typescript-eslint has no configuration to allow using `||` against string. - // https://github.com/typescript-eslint/typescript-eslint/issues/4906 - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return user.name || user.username; + return user.name === '' ? user.username : user.name ?? user.username; } |