diff options
Diffstat (limited to 'packages')
557 files changed, 13577 insertions, 10822 deletions
diff --git a/packages/backend/migration/1683847157541-UserList.js b/packages/backend/migration/1683847157541-UserList.js new file mode 100644 index 0000000000..b50a50eed8 --- /dev/null +++ b/packages/backend/migration/1683847157541-UserList.js @@ -0,0 +1,13 @@ +export class UserList1683847157541 { + name = 'UserList1683847157541' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_list" ADD "isPublic" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_48a00f08598662b9ca540521eb" ON "user_list" ("isPublic") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_48a00f08598662b9ca540521eb"`); + await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "isPublic"`); + } +} diff --git a/packages/backend/migration/1683869758873-UserListFavorites.js b/packages/backend/migration/1683869758873-UserListFavorites.js new file mode 100644 index 0000000000..ac9c4c42b9 --- /dev/null +++ b/packages/backend/migration/1683869758873-UserListFavorites.js @@ -0,0 +1,19 @@ +export class UserListFavorites1683869758873 { + name = 'UserListFavorites1683869758873' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_list_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_c0974b21e18502a4c8178e09fe6" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_016f613dc4feb807e03e3e7da9" ON "user_list_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d6765a8c2a4c17c33f9d7f948b" ON "user_list_favorite" ("userId", "userListId") `); + await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_016f613dc4feb807e03e3e7da92" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2"`); + await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_016f613dc4feb807e03e3e7da92"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d6765a8c2a4c17c33f9d7f948b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_016f613dc4feb807e03e3e7da9"`); + await queryRunner.query(`DROP TABLE "user_list_favorite"`); + } +} diff --git a/packages/backend/migration/1684206886988-remove-showTimelineReplies.js b/packages/backend/migration/1684206886988-remove-showTimelineReplies.js new file mode 100644 index 0000000000..690653bd7c --- /dev/null +++ b/packages/backend/migration/1684206886988-remove-showTimelineReplies.js @@ -0,0 +1,11 @@ +export class RemoveShowTimelineReplies1684206886988 { + name = 'RemoveShowTimelineReplies1684206886988' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1684386446061-emoji-improve.js b/packages/backend/migration/1684386446061-emoji-improve.js new file mode 100644 index 0000000000..40b0a2bc5e --- /dev/null +++ b/packages/backend/migration/1684386446061-emoji-improve.js @@ -0,0 +1,15 @@ +export class EmojiImprove1684386446061 { + name = 'EmojiImprove1684386446061' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "localOnly" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "emoji" ADD "isSensitive" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "emoji" ADD "roleIdsThatCanBeUsedThisEmojiAsReaction" character varying(128) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "roleIdsThatCanBeUsedThisEmojiAsReaction"`); + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "isSensitive"`); + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "localOnly"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 4bab4a7341..56ecbc2eaf 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,49 +35,52 @@ "@swc/core-win32-x64-msvc": "1.3.56", "@tensorflow/tfjs": "4.4.0", "@tensorflow/tfjs-node": "4.4.0", - "slacc-android-arm-eabi": "0.0.7", - "slacc-android-arm64": "0.0.7", - "slacc-darwin-arm64": "0.0.7", - "slacc-darwin-universal": "0.0.7", - "slacc-darwin-x64": "0.0.7", - "slacc-linux-arm-gnueabihf": "0.0.7", - "slacc-linux-arm64-gnu": "0.0.7", - "slacc-linux-arm64-musl": "0.0.7", - "slacc-linux-x64-gnu": "0.0.7", - "slacc-win32-arm64-msvc": "0.0.7", - "slacc-win32-x64-msvc": "0.0.7" + "bufferutil": "^4.0.7", + "slacc-android-arm-eabi": "0.0.9", + "slacc-android-arm64": "0.0.9", + "slacc-darwin-arm64": "0.0.9", + "slacc-darwin-universal": "0.0.9", + "slacc-darwin-x64": "0.0.9", + "slacc-freebsd-x64": "0.0.9", + "slacc-linux-arm-gnueabihf": "0.0.9", + "slacc-linux-arm64-gnu": "0.0.9", + "slacc-linux-arm64-musl": "0.0.9", + "slacc-linux-x64-gnu": "0.0.9", + "slacc-win32-arm64-msvc": "0.0.9", + "slacc-win32-x64-msvc": "0.0.9", + "utf-8-validate": "^6.0.3" }, "dependencies": { "@aws-sdk/client-s3": "3.321.1", "@aws-sdk/lib-storage": "3.321.1", "@aws-sdk/node-http-handler": "3.321.1", - "@bull-board/api": "5.1.2", - "@bull-board/fastify": "5.1.2", - "@bull-board/ui": "5.1.2", + "@bull-board/api": "5.2.0", + "@bull-board/fastify": "5.2.0", + "@bull-board/ui": "5.2.0", "@discordapp/twemoji": "14.1.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", - "@fastify/cors": "8.2.1", + "@fastify/cors": "8.3.0", "@fastify/http-proxy": "9.1.0", "@fastify/multipart": "7.6.0", - "@fastify/static": "6.10.1", + "@fastify/static": "6.10.2", "@fastify/view": "7.4.1", - "@nestjs/common": "9.4.0", - "@nestjs/core": "9.4.0", - "@nestjs/testing": "9.4.0", + "@nestjs/common": "9.4.2", + "@nestjs/core": "9.4.2", + "@nestjs/testing": "9.4.2", "@peertube/http-signature": "1.7.0", - "@sinonjs/fake-timers": "10.0.2", + "@sinonjs/fake-timers": "10.2.0", "@swc/cli": "0.1.62", - "@swc/core": "1.3.56", + "@swc/core": "1.3.61", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", "autwh": "0.1.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", - "bull": "4.10.4", + "bullmq": "3.15.0", "cacheable-lookup": "6.1.0", - "cbor": "8.1.0", + "cbor": "9.0.0", "chalk": "5.2.0", "chalk-template": "0.4.0", "chokidar": "3.5.3", @@ -93,30 +96,30 @@ "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "12.6.0", - "happy-dom": "9.16.0", + "happy-dom": "9.20.3", "hpagent": "1.2.0", "ioredis": "5.3.2", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", - "jsdom": "21.1.1", + "jsdom": "22.1.0", "json5": "2.2.3", - "jsonld": "8.1.1", - "meilisearch": "0.32.3", + "jsonld": "8.2.0", "jsrsasign": "10.8.6", + "meilisearch": "0.32.5", "mfm-js": "0.23.3", "mime-types": "2.1.35", "misskey-js": "workspace:*", "ms": "3.0.0-canary.1", "nested-property": "4.0.0", "node-fetch": "3.3.1", - "nodemailer": "6.9.2", + "nodemailer": "6.9.3", "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", "otpauth": "9.1.2", "parse5": "7.1.2", - "pg": "8.10.0", + "pg": "8.11.0", "private-ip": "3.0.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", @@ -126,7 +129,7 @@ "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.18.0", + "re2": "1.19.0", "redis-lock": "0.1.4", "reflect-metadata": "0.1.13", "rename": "1.0.4", @@ -136,27 +139,26 @@ "s-age": "1.1.2", "sanitize-html": "2.10.0", "seedrandom": "3.0.5", - "semver": "7.5.0", + "semver": "7.5.1", "sharp": "0.32.1", "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", - "slacc": "0.0.7", + "slacc": "0.0.9", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.17.12", + "systeminformation": "5.17.16", "tinycolor2": "1.6.0", "tmp": "0.2.1", "tsc-alias": "1.8.6", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", "typeorm": "0.3.16", - "typescript": "5.0.4", + "typescript": "5.1.3", "ulid": "2.3.0", - "unzipper": "0.10.11", + "unzipper": "0.10.14", "uuid": "9.0.0", "vary": "1.1.2", "web-push": "3.6.1", - "websocket": "1.0.34", "ws": "8.13.0", "xev": "3.0.2" }, @@ -166,23 +168,22 @@ "@types/accepts": "1.3.5", "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", - "@types/bull": "4.10.0", "@types/cbor": "6.0.0", "@types/color-convert": "2.0.0", "@types/content-disposition": "0.5.5", "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.21", - "@types/jest": "29.5.1", + "@types/jest": "29.5.2", "@types/js-yaml": "4.0.5", "@types/jsdom": "21.1.1", "@types/jsonld": "1.5.8", "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "20.1.3", + "@types/node": "20.2.5", "@types/node-fetch": "3.0.3", - "@types/nodemailer": "6.4.7", + "@types/nodemailer": "6.4.8", "@types/oauth": "0.9.1", - "@types/pg": "8.6.6", + "@types/pg": "8.10.1", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", "@types/qrcode": "1.5.0", @@ -196,17 +197,17 @@ "@types/sinonjs__fake-timers": "8.1.2", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", - "@types/unzipper": "0.10.5", + "@types/unzipper": "0.10.6", "@types/uuid": "9.0.1", "@types/vary": "1.1.0", "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.5", - "@typescript-eslint/parser": "5.59.5", + "@typescript-eslint/eslint-plugin": "5.59.8", + "@typescript-eslint/parser": "5.59.8", "aws-sdk-client-mock": "2.1.1", "cross-env": "7.0.3", - "eslint": "8.40.0", + "eslint": "8.41.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", "jest": "29.5.0", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 5fb4e8ef3c..406e3192bb 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -4,7 +4,7 @@ import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; import { MeiliSearch } from 'meilisearch'; import { DI } from './di-symbols.js'; -import { loadConfig } from './config.js'; +import { Config, loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; @@ -25,7 +25,7 @@ const $db: Provider = { const $meilisearch: Provider = { provide: DI.meilisearch, - useFactory: (config) => { + useFactory: (config: Config) => { if (config.meilisearch) { return new MeiliSearch({ host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`, @@ -40,7 +40,7 @@ const $meilisearch: Provider = { const $redis: Provider = { provide: DI.redis, - useFactory: (config) => { + useFactory: (config: Config) => { return new Redis.Redis({ port: config.redis.port, host: config.redis.host, @@ -55,7 +55,7 @@ const $redis: Provider = { const $redisForPub: Provider = { provide: DI.redisForPub, - useFactory: (config) => { + useFactory: (config: Config) => { const redis = new Redis.Redis({ port: config.redisForPubsub.port, host: config.redisForPubsub.host, @@ -71,7 +71,7 @@ const $redisForPub: Provider = { const $redisForSub: Provider = { provide: DI.redisForSub, - useFactory: (config) => { + useFactory: (config: Config) => { const redis = new Redis.Redis({ port: config.redisForPubsub.port, host: config.redisForPubsub.host, @@ -100,7 +100,7 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redisForSub) private redisForSub: Redis.Redis, ) {} - async onApplicationShutdown(signal: string): Promise<void> { + public async dispose(): Promise<void> { if (process.env.NODE_ENV === 'test') { // XXX: // Shutting down the existing connections causes errors on Jest as @@ -116,4 +116,8 @@ export class GlobalModule implements OnApplicationShutdown { this.redisForSub.disconnect(), ]); } + + async onApplicationShutdown(signal: string): Promise<void> { + await this.dispose(); + } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c6e1075389..9d1945e4d4 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -144,7 +144,7 @@ export function loadConfig() { const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json'); const clientManifest = clientManifestExists ? JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8')) - : { 'src/init.ts': { file: 'src/init.ts' } }; + : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; const mixin = {} as Mixin; @@ -165,7 +165,7 @@ export function loadConfig() { mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`; mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`; mixin.userAgent = `Misskey/${meta.version} (${config.url})`; - mixin.clientEntry = clientManifest['src/init.ts']; + mixin.clientEntry = clientManifest['src/_boot_.ts']; mixin.clientManifestExists = clientManifestExists; const externalMediaProxy = config.mediaProxy ? @@ -190,6 +190,6 @@ function tryCreateUrl(url: string) { try { return new URL(url); } catch (e) { - throw `url="${url}" is not a valid URL.`; + throw new Error(`url="${url}" is not a valid URL.`); } } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 2d4226a32d..d8df371916 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -56,11 +56,6 @@ export class AntennaService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisForSub.off('message', this.onRedisMessage); - } - - @bindThis private async onRedisMessage(_: string, data: string): Promise<void> { const obj = JSON.parse(data); @@ -196,4 +191,14 @@ export class AntennaService implements OnApplicationShutdown { return this.antennas; } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onRedisMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index cf1e81ffc8..de33e4c243 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -166,7 +166,12 @@ export class CacheService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { this.redisForSub.off('message', this.onMessage); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index 7aaa1b833f..1a52a229c5 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -30,7 +30,7 @@ export class CaptchaService { }, { throwErrorWhenResponseNotOk: false }); if (!res.ok) { - throw `${res.status}`; + throw new Error(`${res.status}`); } return await res.json() as CaptchaResponse; @@ -39,48 +39,48 @@ export class CaptchaService { @bindThis public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw 'recaptcha-failed: no response provided'; + throw new Error('recaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { - throw `recaptcha-request-failed: ${err}`; + throw new Error(`recaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw `recaptcha-failed: ${errorCodes}`; + throw new Error(`recaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw 'hcaptcha-failed: no response provided'; + throw new Error('hcaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { - throw `hcaptcha-request-failed: ${err}`; + throw new Error(`hcaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw `hcaptcha-failed: ${errorCodes}`; + throw new Error(`hcaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw 'turnstile-failed: no response provided'; + throw new Error('turnstile-failed: no response provided'); } const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { - throw `turnstile-request-failed: ${err}`; + throw new Error(`turnstile-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw `turnstile-failed: ${errorCodes}`; + throw new Error(`turnstile-failed: ${errorCodes}`); } } } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 93557ce617..3499df38b7 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -7,7 +7,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository } from '@/models/index.js'; +import type { EmojisRepository, Role } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -15,6 +15,8 @@ import type { Config } from '@/config.js'; import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/server/api/stream/types.js'; +const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; + @Injectable() export class CustomEmojiService { private cache: MemoryKVCache<Emoji | null>; @@ -63,6 +65,9 @@ export class CustomEmojiService { aliases: string[]; host: string | null; license: string | null; + isSensitive: boolean; + localOnly: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: Role['id'][]; }): Promise<Emoji> { const emoji = await this.emojisRepository.insert({ id: this.idService.genId(), @@ -75,6 +80,9 @@ export class CustomEmojiService { publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, type: data.driveFile.webpublicType ?? data.driveFile.type, license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { @@ -90,10 +98,14 @@ export class CustomEmojiService { @bindThis public async update(id: Emoji['id'], data: { + driveFile?: DriveFile; name?: string; category?: string | null; aliases?: string[]; license?: string | null; + isSensitive?: boolean; + localOnly?: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction?: Role['id'][]; }): Promise<void> { const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); @@ -105,6 +117,12 @@ export class CustomEmojiService { category: data.category, aliases: data.aliases, license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + originalUrl: data.driveFile != null ? data.driveFile.url : undefined, + publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, + type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, + roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, }); this.localEmojisCache.refresh(); @@ -259,7 +277,7 @@ export class CustomEmojiService { @bindThis public parseEmojiStr(emojiName: string, noteUserHost: string | null) { - const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + const match = emojiName.match(parseEmojiStrRegexp); if (!match) return { name: null, host: null }; const name = match[1]; diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 8103d5afe9..9de633350b 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -116,14 +116,14 @@ export class FetchInstanceMetadataService { const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') .catch(err => { if (err.statusCode === 404) { - throw 'No nodeinfo provided'; + throw new Error('No nodeinfo provided'); } else { throw err.statusCode ?? err.message; } }) as Record<string, unknown>; if (wellknown.links == null || !Array.isArray(wellknown.links)) { - throw 'No wellknown links'; + throw new Error('No wellknown links'); } const links = wellknown.links as any[]; @@ -134,7 +134,7 @@ export class FetchInstanceMetadataService { const link = lnik2_1 ?? lnik2_0 ?? lnik1_0; if (link == null) { - throw 'No nodeinfo link provided'; + throw new Error('No nodeinfo link provided'); } const info = await this.httpRequestService.getJson(link.href) diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 0b861be8d0..5acc9ad9ad 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -120,8 +120,13 @@ export class MetaService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { clearInterval(this.intervalId); this.redisForSub.off('message', this.onMessage); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 9b2d5dc0ff..dffee16e08 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -83,7 +83,7 @@ export class MfmService { if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { text += txt; // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { const part = txt.split('@'); if (part.length === 2 && href) { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 977c9052c0..1c8491bf57 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -510,7 +510,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.poll && data.poll.expiresAt) { const delay = data.poll.expiresAt.getTime() - Date.now(); - this.queueService.endedPollNotificationQueue.add({ + this.queueService.endedPollNotificationQueue.add(note.id, { noteId: note.id, }, { delay, @@ -790,7 +790,13 @@ export class NoteCreateService implements OnApplicationShutdown { return mentionedUsers; } - onApplicationShutdown(signal?: string | undefined) { + @bindThis + public dispose(): void { this.#shutdownController.abort(); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 1129bd159c..e57e57d310 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -122,7 +122,13 @@ export class NoteReadService implements OnApplicationShutdown { } } - onApplicationShutdown(signal?: string | undefined): void { + @bindThis + public dispose(): void { this.#shutdownController.abort(); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index a245908c98..ed47165f7b 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -152,7 +152,13 @@ export class NotificationService implements OnApplicationShutdown { */ } - onApplicationShutdown(signal?: string | undefined): void { + @bindThis + public dispose(): void { this.#shutdownController.abort(); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 0cee2076bf..bf50a1cded 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -208,7 +208,7 @@ export class QueryService { } @bindThis - public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void { + public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<User, 'id'> | null): void { if (me == null) { q.andWhere(new Brackets(qb => { qb .where('note.replyId IS NULL') // 返信ではない @@ -217,7 +217,7 @@ export class QueryService { .andWhere('note.replyUserId = note.userId'); })); })); - } else if (!me.showTimelineReplies) { + } else if (!withReplies) { q.andWhere(new Brackets(qb => { qb .where('note.replyId IS NULL') // 返信ではない .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 1d73947776..3384ca4577 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -1,42 +1,11 @@ import { setTimeout } from 'node:timers/promises'; import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; -import Bull from 'bull'; +import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; +import { QUEUE, baseQueueOptions } from '@/queue/const.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js'; - -function q<T>(config: Config, name: string, limitPerSec = -1) { - return new Bull<T>(name, { - redis: { - port: config.redisForJobQueue.port, - host: config.redisForJobQueue.host, - family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, - password: config.redisForJobQueue.pass, - db: config.redisForJobQueue.db ?? 0, - }, - prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue', - limiter: limitPerSec > 0 ? { - max: limitPerSec, - duration: 1000, - } : undefined, - settings: { - backoffStrategies: { - apBackoff, - }, - }, - }); -} - -// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 -function apBackoff(attemptsMade: number, err: Error) { - const baseDelay = 60 * 1000; // 1min - const maxBackoff = 8 * 60 * 60 * 1000; // 8hours - let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; - backoff = Math.min(backoff, maxBackoff); - backoff += Math.round(backoff * Math.random() * 0.2); - return backoff; -} +import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js'; export type SystemQueue = Bull.Queue<Record<string, unknown>>; export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>; @@ -49,49 +18,49 @@ export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>; const $system: Provider = { provide: 'queue:system', - useFactory: (config: Config) => q(config, 'system'), + useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)), inject: [DI.config], }; const $endedPollNotification: Provider = { provide: 'queue:endedPollNotification', - useFactory: (config: Config) => q(config, 'endedPollNotification'), + useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)), inject: [DI.config], }; const $deliver: Provider = { provide: 'queue:deliver', - useFactory: (config: Config) => q(config, 'deliver', config.deliverJobPerSec ?? 128), + useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), inject: [DI.config], }; const $inbox: Provider = { provide: 'queue:inbox', - useFactory: (config: Config) => q(config, 'inbox', config.inboxJobPerSec ?? 16), + useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)), inject: [DI.config], }; const $db: Provider = { provide: 'queue:db', - useFactory: (config: Config) => q(config, 'db'), + useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)), inject: [DI.config], }; const $relationship: Provider = { provide: 'queue:relationship', - useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64), + useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)), inject: [DI.config], }; const $objectStorage: Provider = { provide: 'queue:objectStorage', - useFactory: (config: Config) => q(config, 'objectStorage'), + useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)), inject: [DI.config], }; const $webhookDeliver: Provider = { provide: 'queue:webhookDeliver', - useFactory: (config: Config) => q(config, 'webhookDeliver', 64), + useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)), inject: [DI.config], }; @@ -131,7 +100,7 @@ export class QueueModule implements OnApplicationShutdown { @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) {} - async onApplicationShutdown(signal: string): Promise<void> { + public async dispose(): Promise<void> { if (process.env.NODE_ENV === 'test') { // XXX: // Shutting down the existing connections causes errors on Jest as @@ -151,4 +120,8 @@ export class QueueModule implements OnApplicationShutdown { this.webhookDeliverQueue.close(), ]); } + + async onApplicationShutdown(signal: string): Promise<void> { + await this.dispose(); + } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index b4ffffecc0..2ae8a2b754 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; -import Bull from 'bull'; import type { IActivity } from '@/core/activitypub/type.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; @@ -11,6 +10,7 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; +import type * as Bull from 'bullmq'; @Injectable() export class QueueService { @@ -26,7 +26,43 @@ export class QueueService { @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, - ) {} + ) { + this.systemQueue.add('tickCharts', { + }, { + repeat: { pattern: '55 * * * *' }, + removeOnComplete: true, + }); + + this.systemQueue.add('resyncCharts', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.systemQueue.add('cleanCharts', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.systemQueue.add('aggregateRetention', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.systemQueue.add('clean', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); + + this.systemQueue.add('checkExpiredMutings', { + }, { + repeat: { pattern: '*/5 * * * *' }, + removeOnComplete: true, + }); + } @bindThis public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { @@ -42,11 +78,10 @@ export class QueueService { isSharedInbox, }; - return this.deliverQueue.add(data, { + return this.deliverQueue.add(to, data, { attempts: this.config.deliverJobMaxAttempts ?? 12, - timeout: 1 * 60 * 1000, // 1min backoff: { - type: 'apBackoff', + type: 'custom', }, removeOnComplete: true, removeOnFail: true, @@ -60,11 +95,10 @@ export class QueueService { signature, }; - return this.inboxQueue.add(data, { + return this.inboxQueue.add('', data, { attempts: this.config.inboxJobMaxAttempts ?? 8, - timeout: 5 * 60 * 1000, // 5min backoff: { - type: 'apBackoff', + type: 'custom', }, removeOnComplete: true, removeOnFail: true, @@ -212,7 +246,7 @@ export class QueueService { private generateToDbJobData<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(name: T, data: D): { name: string, data: D, - opts: Bull.JobOptions, + opts: Bull.JobsOptions, } { return { name, @@ -299,10 +333,10 @@ export class QueueService { } @bindThis - private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): { + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobsOptions = {}): { name: string, data: RelationshipJobData, - opts: Bull.JobOptions, + opts: Bull.JobsOptions, } { return { name, @@ -351,11 +385,10 @@ export class QueueService { eventId: uuid(), }; - return this.webhookDeliverQueue.add(data, { + return this.webhookDeliverQueue.add(webhook.id, data, { attempts: 4, - timeout: 1 * 60 * 1000, // 1min backoff: { - type: 'apBackoff', + type: 'custom', }, removeOnComplete: true, removeOnFail: true, @@ -367,11 +400,11 @@ export class QueueService { this.deliverQueue.once('cleaned', (jobs, status) => { //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); - this.deliverQueue.clean(0, 'delayed'); + this.deliverQueue.clean(0, Infinity, 'delayed'); this.inboxQueue.once('cleaned', (jobs, status) => { //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); - this.inboxQueue.clean(0, 'delayed'); + this.inboxQueue.clean(0, Infinity, 'delayed'); } } diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index a274b19e4b..4b01b6af7e 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { RoleService } from '@/core/RoleService.js'; const FALLBACK = '❤'; @@ -54,6 +55,9 @@ type DecodedReaction = { host?: string | null; }; +const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; +const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; + @Injectable() export class ReactionService { constructor( @@ -72,6 +76,7 @@ export class ReactionService { private utilityService: UtilityService, private metaService: MetaService, private customEmojiService: CustomEmojiService, + private roleService: RoleService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, @@ -85,7 +90,7 @@ export class ReactionService { } @bindThis - public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string | null) { + public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, _reaction?: string | null) { // Check blocking if (note.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); @@ -99,10 +104,41 @@ export class ReactionService { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } - if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { + let reaction = _reaction ?? FALLBACK; + + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) { reaction = '❤️'; - } else { - reaction = await this.toDbReaction(reaction, user.host); + } else if (_reaction) { + const custom = reaction.match(isCustomEmojiRegexp); + if (custom) { + const reacterHost = this.utilityService.toPunyNullable(user.host); + + const name = custom[1]; + const emoji = reacterHost == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) + : await this.emojisRepository.findOneBy({ + host: reacterHost, + name, + }); + + if (emoji) { + if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) { + reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; + + // センシティブ + if ((note.reactionAcceptance === 'nonSensitiveOnly') && emoji.isSensitive) { + reaction = FALLBACK; + } + } else { + // リアクションとして使う権限がない + reaction = FALLBACK; + } + } else { + reaction = FALLBACK; + } + } else { + reaction = this.normalize(reaction ?? null); + } } const record: NoteReaction = { @@ -288,11 +324,9 @@ export class ReactionService { } @bindThis - public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { + public normalize(reaction: string | null): string { if (reaction == null) return FALLBACK; - reacterHost = this.utilityService.toPunyNullable(reacterHost); - // 文字列タイプのリアクションを絵文字に変換 if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; @@ -306,25 +340,12 @@ export class ReactionService { return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); } - const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); - if (custom) { - const name = custom[1]; - const emoji = reacterHost == null - ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) - : await this.emojisRepository.findOneBy({ - host: reacterHost, - name, - }); - - if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; - } - return FALLBACK; } @bindThis public decodeReaction(str: string): DecodedReaction { - const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); + const custom = str.match(decodeCustomEmojiRegexp); if (custom) { const name = custom[1]; diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 68087ccc3b..40ae106662 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -307,6 +307,14 @@ export class RoleService implements OnApplicationShutdown { } @bindThis + public async isExplorable(role: { id: Role['id']} | null): Promise<boolean> { + if (role == null) return false; + const check = await this.rolesRepository.findOneBy({ id: role.id }); + if (check == null) return false; + return check.isExplorable; + } + + @bindThis public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); @@ -425,7 +433,12 @@ export class RoleService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { this.redisForSub.off('message', this.onMessage); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 3ee7990643..f58a6a10fc 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -16,6 +16,9 @@ type IWebFinger = { subject: string; }; +const urlRegex = /^https?:\/\//; +const mRegex = /^([^@]+)@(.*)/; + @Injectable() export class WebfingerService { constructor( @@ -35,12 +38,12 @@ export class WebfingerService { @bindThis private genUrl(query: string): string { - if (query.match(/^https?:\/\//)) { + if (query.match(urlRegex)) { const u = new URL(query); return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); } - const m = query.match(/^([^@]+)@(.*)/); + const m = query.match(mRegex); if (m) { const hostname = m[2]; const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 57baade777..467755a072 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -81,7 +81,12 @@ export class WebhookService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { this.redisForSub.off('message', this.onMessage); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 60e19bfca5..d8b95ca4d1 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -277,7 +277,7 @@ export class ApRendererService { const name = reaction.replaceAll(':', ''); const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); - if (emoji) object.tag = [this.renderEmoji(emoji)]; + if (emoji && !emoji.localOnly) object.tag = [this.renderEmoji(emoji)]; } return object; @@ -400,7 +400,7 @@ export class ApRendererService { })); const emojis = await this.getEmojis(note.emojis); - const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); + const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); const tag = [ ...hashtagTags, @@ -479,7 +479,7 @@ export class ApRendererService { } const emojis = await this.getEmojis(user.emojis); - const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); + const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag)); diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts index 2dc1a410ac..20fe2a0a77 100644 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ b/packages/backend/src/core/activitypub/LdSignatureService.ts @@ -94,7 +94,7 @@ class LdSignature { @bindThis private getLoader() { return async (url: string): Promise<any> => { - if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`; + if (!url.match('^https?\:\/\/')) throw new Error(`Invalid URL ${url}`); if (this.preLoad) { if (url in CONTEXTS) { @@ -126,7 +126,7 @@ class LdSignature { timeout: this.loderTimeout, }, { throwErrorWhenResponseNotOk: false }).then(res => { if (!res.ok) { - throw `${res.status} ${res.statusText}`; + throw new Error(`${res.status} ${res.statusText}`); } else { return res.json(); } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 87a9db405f..76757f530a 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -18,6 +18,7 @@ import { PollService } from '@/core/PollService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { checkHttps } from '@/misc/check-https.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import { ApLoggerService } from '../ApLoggerService.js'; @@ -32,7 +33,6 @@ import { ApQuestionService } from './ApQuestionService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IPost } from '../type.js'; -import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApNoteService { @@ -230,7 +230,7 @@ export class ApNoteService { quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); if (!quote) { if (results.some(x => x.status === 'temperror')) { - throw 'quote resolve failed'; + throw new Error('quote resolve failed'); } } } @@ -311,7 +311,7 @@ export class ApNoteService { // ブロックしてたら中断 const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw { statusCode: 451 }; + if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451); const unlock = await this.appLockService.getApLock(uri); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index eea1d1b848..f52ebed107 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -32,6 +32,8 @@ import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import type { AccountMoveService } from '@/core/AccountMoveService.js'; +import { checkHttps } from '@/misc/check-https.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -42,8 +44,6 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, IObject } from '../type.js'; -import type { AccountMoveService } from '@/core/AccountMoveService.js'; -import { checkHttps } from '@/misc/check-https.js'; const nameLength = 128; const summaryLength = 2048; @@ -306,7 +306,6 @@ export class ApPersonService implements OnModuleInit { tags, isBot, isCat: (person as any).isCat === true, - showTimelineReplies: false, })) as RemoteUser; await transactionalEntityManager.save(new UserProfile({ @@ -696,7 +695,7 @@ export class ApPersonService implements OnModuleInit { if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { return 'skip: dst.alsoKnownAs is empty'; } - if (!dst.alsoKnownAs?.includes(src.uri)) { + if (!dst.alsoKnownAs.includes(src.uri)) { return 'skip: alsoKnownAs does not include from.uri'; } diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index 03e3612658..b0e9e534df 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -60,7 +60,8 @@ export class ChartManagementService implements OnApplicationShutdown { }, 1000 * 60 * 20); } - async onApplicationShutdown(signal: string): Promise<void> { + @bindThis + public async dispose(): Promise<void> { clearInterval(this.saveIntervalId); if (process.env.NODE_ENV !== 'test') { await Promise.all( @@ -68,4 +69,9 @@ export class ChartManagementService implements OnApplicationShutdown { ); } } + + @bindThis + async onApplicationShutdown(signal: string): Promise<void> { + await this.dispose(); + } } diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 3bad048bc0..4a18cd1b3b 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -26,6 +26,8 @@ export class EmojiEntityService { category: emoji.category, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, + isSensitive: emoji.isSensitive ? true : undefined, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, }; } @@ -51,6 +53,9 @@ export class EmojiEntityService { // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, license: emoji.license, + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, }; } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 7f61e1d6f3..bfd506ea86 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -466,7 +466,6 @@ export class UserEntityService implements OnModuleInit { mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, - showTimelineReplies: user.showTimelineReplies ?? falsy, achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, policies: this.roleService.getUserPolicies(user.id), diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 2461cb2c12..8628819278 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -35,6 +35,7 @@ export class UserListEntityService { createdAt: userList.createdAt.toISOString(), name: userList.name, userIds: users.map(x => x.userId), + isPublic: userList.isPublic, }; } } diff --git a/packages/backend/src/daemons/JanitorService.ts b/packages/backend/src/daemons/JanitorService.ts index 8cdfb703f1..f826d50625 100644 --- a/packages/backend/src/daemons/JanitorService.ts +++ b/packages/backend/src/daemons/JanitorService.ts @@ -34,7 +34,12 @@ export class JanitorService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { clearInterval(this.intervalId); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts index b717434e09..53a0d14cd7 100644 --- a/packages/backend/src/daemons/QueueStatsService.ts +++ b/packages/backend/src/daemons/QueueStatsService.ts @@ -1,7 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import Xev from 'xev'; +import * as Bull from 'bullmq'; import { QueueService } from '@/core/QueueService.js'; import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { QUEUE, baseQueueOptions } from '@/queue/const.js'; import type { OnApplicationShutdown } from '@nestjs/common'; const ev = new Xev(); @@ -13,6 +17,9 @@ export class QueueStatsService implements OnApplicationShutdown { private intervalId: NodeJS.Timer; constructor( + @Inject(DI.config) + private config: Config, + private queueService: QueueService, ) { } @@ -31,11 +38,14 @@ export class QueueStatsService implements OnApplicationShutdown { let activeDeliverJobs = 0; let activeInboxJobs = 0; - this.queueService.deliverQueue.on('global:active', () => { + const deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER)); + const inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX)); + + deliverQueueEvents.on('active', () => { activeDeliverJobs++; }); - this.queueService.inboxQueue.on('global:active', () => { + inboxQueueEvents.on('active', () => { activeInboxJobs++; }); @@ -71,9 +81,14 @@ export class QueueStatsService implements OnApplicationShutdown { this.intervalId = setInterval(tick, interval); } - + @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { clearInterval(this.intervalId); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index bb190cf60f..6cd71c0e2a 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -63,9 +63,14 @@ export class ServerStatsService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { clearInterval(this.intervalId); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } // CPU STAT diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index c06c7a7159..4a073f102f 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -25,6 +25,7 @@ export const DI = { userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), userPublickeysRepository: Symbol('userPublickeysRepository'), userListsRepository: Symbol('userListsRepository'), + userListFavoritesRepository: Symbol('userListFavoritesRepository'), userListJoiningsRepository: Symbol('userListJoiningsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'), userIpsRepository: Symbol('userIpsRepository'), diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 9e206ee98f..f0cbc9900d 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -21,7 +21,7 @@ function getNoise(): string { export function genAid(date: Date): string { const t = date.getTime(); - if (isNaN(t)) throw 'Failed to create AID: Invalid Date'; + if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date'); counter++; return getTime(t) + getNoise(); } diff --git a/packages/backend/src/misc/prelude/time.ts b/packages/backend/src/misc/prelude/time.ts index 34e8b6b17c..b21978b186 100644 --- a/packages/backend/src/misc/prelude/time.ts +++ b/packages/backend/src/misc/prelude/time.ts @@ -5,15 +5,16 @@ const dateTimeIntervals = { }; export function dateUTC(time: number[]): Date { - const d = time.length === 2 ? Date.UTC(time[0], time[1]) - : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) - : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) - : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) - : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) - : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) - : null; + const d = + time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; - if (!d) throw 'wrong number of arguments'; + if (!d) throw new Error('wrong number of arguments'); return new Date(d); } diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 588c98b58d..4231acc046 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -112,6 +112,12 @@ const $userListsRepository: Provider = { inject: [DI.db], }; +const $userListFavoritesRepository: Provider = { + provide: DI.userListFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(UserListFavorite), + inject: [DI.db], +}; + const $userListJoiningsRepository: Provider = { provide: DI.userListJoiningsRepository, useFactory: (db: DataSource) => db.getRepository(UserListJoining), @@ -416,6 +422,7 @@ const $userMemosRepository: Provider = { $userSecurityKeysRepository, $userPublickeysRepository, $userListsRepository, + $userListFavoritesRepository, $userListJoiningsRepository, $userNotePiningsRepository, $userIpsRepository, @@ -483,6 +490,7 @@ const $userMemosRepository: Provider = { $userSecurityKeysRepository, $userPublickeysRepository, $userListsRepository, + $userListFavoritesRepository, $userListJoiningsRepository, $userNotePiningsRepository, $userIpsRepository, diff --git a/packages/backend/src/models/entities/Emoji.ts b/packages/backend/src/models/entities/Emoji.ts index dbb437d439..8fd3e65f5e 100644 --- a/packages/backend/src/models/entities/Emoji.ts +++ b/packages/backend/src/models/entities/Emoji.ts @@ -60,4 +60,20 @@ export class Emoji { length: 1024, nullable: true, }) public license: string | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; + + // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; } diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index df508b4dca..4f49a05950 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -90,7 +90,7 @@ export class Note { @Column('varchar', { length: 64, nullable: true, }) - public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; + public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; @Column('smallint', { default: 0, diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 8e10f999b6..6669890cf6 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -232,12 +232,6 @@ export class User { }) public followersUri: string | null; - @Column('boolean', { - default: false, - comment: 'Whether to show users replying to other users in the timeline.', - }) - public showTimelineReplies: boolean; - @Index({ unique: true }) @Column('char', { length: 16, nullable: true, unique: true, diff --git a/packages/backend/src/models/entities/UserList.ts b/packages/backend/src/models/entities/UserList.ts index b8a4b54d4c..94f3dc3cb3 100644 --- a/packages/backend/src/models/entities/UserList.ts +++ b/packages/backend/src/models/entities/UserList.ts @@ -19,6 +19,12 @@ export class UserList { }) public userId: User['id']; + @Index() + @Column('boolean', { + default: false, + }) + public isPublic: boolean; + @ManyToOne(type => User, { onDelete: 'CASCADE', }) diff --git a/packages/backend/src/models/entities/UserListFavorite.ts b/packages/backend/src/models/entities/UserListFavorite.ts new file mode 100644 index 0000000000..e57abb460a --- /dev/null +++ b/packages/backend/src/models/entities/UserListFavorite.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { UserList } from './UserList.js'; + +@Entity() +@Index(['userId', 'userListId'], { unique: true }) +export class UserListFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public userListId: UserList['id']; + + @ManyToOne(type => UserList, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userList: UserList | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index b8ba28db9b..4b230ab742 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js'; import { UserIp } from '@/models/entities/UserIp.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserList } from '@/models/entities/UserList.js'; +import { UserListFavorite } from './entities/UserListFavorite.js'; import { UserListJoining } from '@/models/entities/UserListJoining.js'; import { UserNotePining } from '@/models/entities/UserNotePining.js'; import { UserPending } from '@/models/entities/UserPending.js'; @@ -117,6 +118,7 @@ export { UserIp, UserKeypair, UserList, + UserListFavorite, UserListJoining, UserNotePining, UserPending, @@ -184,6 +186,7 @@ export type UsersRepository = Repository<User>; export type UserIpsRepository = Repository<UserIp>; export type UserKeypairsRepository = Repository<UserKeypair>; export type UserListsRepository = Repository<UserList>; +export type UserListFavoritesRepository = Repository<UserListFavorite>; export type UserListJoiningsRepository = Repository<UserListJoining>; export type UserNotePiningsRepository = Repository<UserNotePining>; export type UserPendingsRepository = Repository<UserPending>; diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index db4fd62cf6..63f56e77cb 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -22,6 +22,19 @@ export const packedEmojiSimpleSchema = { type: 'string', optional: false, nullable: false, }, + isSensitive: { + type: 'boolean', + optional: true, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, }, } as const; @@ -63,5 +76,22 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: true, }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts index 3ba5dc4a8a..1e620516e4 100644 --- a/packages/backend/src/models/json-schema/user-list.ts +++ b/packages/backend/src/models/json-schema/user-list.ts @@ -25,5 +25,10 @@ export const packedUserListSchema = { format: 'id', }, }, + isPublic: { + type: 'boolean', + nullable: false, + optional: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index f3d404e6c9..488979c409 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -57,6 +57,7 @@ import { User } from '@/models/entities/User.js'; import { UserIp } from '@/models/entities/UserIp.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserList } from '@/models/entities/UserList.js'; +import { UserListFavorite } from '@/models/entities/UserListFavorite.js'; import { UserListJoining } from '@/models/entities/UserListJoining.js'; import { UserNotePining } from '@/models/entities/UserNotePining.js'; import { UserPending } from '@/models/entities/UserPending.js'; @@ -132,6 +133,7 @@ export const entities = [ UserKeypair, UserPublickey, UserList, + UserListFavorite, UserListJoining, UserNotePining, UserSecurityKey, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index dc025f9889..42f9c1af7d 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -1,10 +1,9 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Bull from 'bullmq'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; -import { QueueService } from '@/core/QueueService.js'; import { bindThis } from '@/decorators.js'; -import { getJobInfo } from './get-job-info.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; @@ -35,17 +34,51 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; +import { QUEUE, baseQueueOptions } from './const.js'; + +// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 +function httpRelatedBackoff(attemptsMade: number) { + const baseDelay = 60 * 1000; // 1min + const maxBackoff = 8 * 60 * 60 * 1000; // 8hours + let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; + backoff = Math.min(backoff, maxBackoff); + backoff += Math.round(backoff * Math.random() * 0.2); + return backoff; +} + +function getJobInfo(job: Bull.Job | undefined, increment = false): string { + if (job == null) return '-'; + + const age = Date.now() - job.timestamp; + + const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m` + : age > 10000 ? `${Math.floor(age / 1000)}s` + : `${age}ms`; + + // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする + const currentAttempts = job.attemptsMade + (increment ? 1 : 0); + const maxAttempts = job.opts ? job.opts.attempts : 0; + + return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; +} @Injectable() -export class QueueProcessorService { +export class QueueProcessorService implements OnApplicationShutdown { private logger: Logger; + private systemQueueWorker: Bull.Worker; + private dbQueueWorker: Bull.Worker; + private deliverQueueWorker: Bull.Worker; + private inboxQueueWorker: Bull.Worker; + private webhookDeliverQueueWorker: Bull.Worker; + private relationshipQueueWorker: Bull.Worker; + private objectStorageQueueWorker: Bull.Worker; + private endedPollNotificationQueueWorker: Bull.Worker; constructor( @Inject(DI.config) private config: Config, private queueLoggerService: QueueLoggerService, - private queueService: QueueService, private webhookDeliverProcessorService: WebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, @@ -77,10 +110,7 @@ export class QueueProcessorService { private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; - } - @bindThis - public start() { function renderError(e: Error): any { if (e) { // 何故かeがundefinedで来ることがある return { @@ -97,146 +127,232 @@ export class QueueProcessorService { } } + //#region system + this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { + switch (job.name) { + case 'tickCharts': return this.tickChartsProcessorService.process(); + case 'resyncCharts': return this.resyncChartsProcessorService.process(); + case 'cleanCharts': return this.cleanChartsProcessorService.process(); + case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); + case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'clean': return this.cleanProcessorService.process(); + default: throw new Error(`unrecognized job type ${job.name} for system`); + } + }, { + ...baseQueueOptions(this.config, QUEUE.SYSTEM), + autorun: false, + }); + const systemLogger = this.logger.createSubLogger('system'); - const deliverLogger = this.logger.createSubLogger('deliver'); - const webhookLogger = this.logger.createSubLogger('webhook'); - const inboxLogger = this.logger.createSubLogger('inbox'); - const dbLogger = this.logger.createSubLogger('db'); - const relationshipLogger = this.logger.createSubLogger('relationship'); - const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - this.queueService.systemQueue - .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) + this.systemQueueWorker .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); + .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) + .on('error', (err: Error) => systemLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`)); + //#endregion + + //#region db + this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { + switch (job.name) { + case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); + case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); + case 'exportNotes': return this.exportNotesProcessorService.process(job); + case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); + case 'exportFollowing': return this.exportFollowingProcessorService.process(job); + case 'exportMuting': return this.exportMutingProcessorService.process(job); + case 'exportBlocking': return this.exportBlockingProcessorService.process(job); + case 'exportUserLists': return this.exportUserListsProcessorService.process(job); + case 'exportAntennas': return this.exportAntennasProcessorService.process(job); + case 'importFollowing': return this.importFollowingProcessorService.process(job); + case 'importFollowingToDb': return this.importFollowingProcessorService.processDb(job); + case 'importMuting': return this.importMutingProcessorService.process(job); + case 'importBlocking': return this.importBlockingProcessorService.process(job); + case 'importBlockingToDb': return this.importBlockingProcessorService.processDb(job); + case 'importUserLists': return this.importUserListsProcessorService.process(job); + case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job); + case 'importAntennas': return this.importAntennasProcessorService.process(job); + case 'deleteAccount': return this.deleteAccountProcessorService.process(job); + default: throw new Error(`unrecognized job type ${job.name} for db`); + } + }, { + ...baseQueueOptions(this.config, QUEUE.DB), + autorun: false, + }); + + const dbLogger = this.logger.createSubLogger('db'); + + this.dbQueueWorker + .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) + .on('error', (err: Error) => dbLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`)); + //#endregion - this.queueService.deliverQueue - .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) + //#region deliver + this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => this.deliverProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.DELIVER), + autorun: false, + concurrency: this.config.deliverJobConcurrency ?? 128, + limiter: { + max: this.config.deliverJobPerSec ?? 128, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); + + const deliverLogger = this.logger.createSubLogger('deliver'); + + this.deliverQueueWorker .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) - .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`)) + .on('error', (err: Error) => deliverLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`)); + //#endregion + + //#region inbox + this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => this.inboxProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.INBOX), + autorun: false, + concurrency: this.config.inboxJobConcurrency ?? 16, + limiter: { + max: this.config.inboxJobPerSec ?? 16, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); - this.queueService.inboxQueue - .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) + const inboxLogger = this.logger.createSubLogger('inbox'); + + this.inboxQueueWorker .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) - .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); + .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) })) + .on('error', (err: Error) => inboxLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`)); + //#endregion - this.queueService.dbQueue - .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); - - this.queueService.relationshipQueue - .on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`)); + //#region webhook deliver + this.webhookDeliverQueueWorker = new Bull.Worker(QUEUE.WEBHOOK_DELIVER, (job) => this.webhookDeliverProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.WEBHOOK_DELIVER), + autorun: false, + concurrency: 64, + limiter: { + max: 64, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); - this.queueService.objectStorageQueue - .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) - .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) - .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); + const webhookLogger = this.logger.createSubLogger('webhook'); - this.queueService.webhookDeliverQueue - .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) + this.webhookDeliverQueueWorker .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) - .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) - .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`)) + .on('error', (err: Error) => webhookLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`)); + //#endregion - this.queueService.systemQueue.add('tickCharts', { + //#region relationship + this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { + switch (job.name) { + case 'follow': return this.relationshipProcessorService.processFollow(job); + case 'unfollow': return this.relationshipProcessorService.processUnfollow(job); + case 'block': return this.relationshipProcessorService.processBlock(job); + case 'unblock': return this.relationshipProcessorService.processUnblock(job); + default: throw new Error(`unrecognized job type ${job.name} for relationship`); + } }, { - repeat: { cron: '55 * * * *' }, - removeOnComplete: true, + ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), + autorun: false, + concurrency: this.config.relashionshipJobConcurrency ?? 16, + limiter: { + max: this.config.relashionshipJobPerSec ?? 64, + duration: 1000, + }, }); - this.queueService.systemQueue.add('resyncCharts', { - }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, - }); + const relationshipLogger = this.logger.createSubLogger('relationship'); + + this.relationshipQueueWorker + .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) + .on('error', (err: Error) => relationshipLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`)); + //#endregion - this.queueService.systemQueue.add('cleanCharts', { + //#region object storage + this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { + switch (job.name) { + case 'deleteFile': return this.deleteFileProcessorService.process(job); + case 'cleanRemoteFiles': return this.cleanRemoteFilesProcessorService.process(job); + default: throw new Error(`unrecognized job type ${job.name} for objectStorage`); + } }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, + ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), + autorun: false, + concurrency: 16, }); - this.queueService.systemQueue.add('aggregateRetention', { - }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, - }); + const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - this.queueService.systemQueue.add('clean', { - }, { - repeat: { cron: '0 0 * * *' }, - removeOnComplete: true, - }); + this.objectStorageQueueWorker + .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) + .on('error', (err: Error) => objectStorageLogger.error(`error ${err}`, { e: renderError(err) })) + .on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`)); + //#endregion - this.queueService.systemQueue.add('checkExpiredMutings', { - }, { - repeat: { cron: '*/5 * * * *' }, - removeOnComplete: true, + //#region ended poll notification + this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => this.endedPollNotificationProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), + autorun: false, }); + //#endregion + } - this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); - this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); - this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); - this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); - - this.queueService.dbQueue.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); - this.queueService.dbQueue.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done)); - this.queueService.dbQueue.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); - this.queueService.dbQueue.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); - this.queueService.dbQueue.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); - this.queueService.dbQueue.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); - this.queueService.dbQueue.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); - this.queueService.dbQueue.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); - this.queueService.dbQueue.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); - this.queueService.dbQueue.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done)); - this.queueService.dbQueue.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); + @bindThis + public async start(): Promise<void> { + await Promise.all([ + this.systemQueueWorker.run(), + this.dbQueueWorker.run(), + this.deliverQueueWorker.run(), + this.inboxQueueWorker.run(), + this.webhookDeliverQueueWorker.run(), + this.relationshipQueueWorker.run(), + this.objectStorageQueueWorker.run(), + this.endedPollNotificationQueueWorker.run(), + ]); + } - this.queueService.objectStorageQueue.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job)); - this.queueService.objectStorageQueue.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done)); - - { - const maxJobs = this.config.relashionshipJobConcurrency ?? 16; - this.queueService.relationshipQueue.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); - this.queueService.relationshipQueue.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); - this.queueService.relationshipQueue.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); - this.queueService.relationshipQueue.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job)); - } + @bindThis + public async stop(): Promise<void> { + await Promise.all([ + this.systemQueueWorker.close(), + this.dbQueueWorker.close(), + this.deliverQueueWorker.close(), + this.inboxQueueWorker.close(), + this.webhookDeliverQueueWorker.close(), + this.relationshipQueueWorker.close(), + this.objectStorageQueueWorker.close(), + this.endedPollNotificationQueueWorker.close(), + ]); + } - this.queueService.systemQueue.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done)); - this.queueService.systemQueue.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done)); - this.queueService.systemQueue.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done)); - this.queueService.systemQueue.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done)); - this.queueService.systemQueue.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done)); - this.queueService.systemQueue.process('clean', (job, done) => this.cleanProcessorService.process(job, done)); + @bindThis + public async onApplicationShutdown(signal?: string | undefined): Promise<void> { + await this.stop(); } } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts new file mode 100644 index 0000000000..d240fe70e0 --- /dev/null +++ b/packages/backend/src/queue/const.ts @@ -0,0 +1,26 @@ +import { Config } from '@/config.js'; +import type * as Bull from 'bullmq'; + +export const QUEUE = { + DELIVER: 'deliver', + INBOX: 'inbox', + SYSTEM: 'system', + ENDED_POLL_NOTIFICATION: 'endedPollNotification', + DB: 'db', + RELATIONSHIP: 'relationship', + OBJECT_STORAGE: 'objectStorage', + WEBHOOK_DELIVER: 'webhookDeliver', +}; + +export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { + return { + connection: { + port: config.redisForJobQueue.port, + host: config.redisForJobQueue.host, + family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, + password: config.redisForJobQueue.pass, + db: config.redisForJobQueue.db ?? 0, + }, + prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, + }; +} diff --git a/packages/backend/src/queue/get-job-info.ts b/packages/backend/src/queue/get-job-info.ts deleted file mode 100644 index d33e349c36..0000000000 --- a/packages/backend/src/queue/get-job-info.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Bull from 'bull'; - -export function getJobInfo(job: Bull.Job, increment = false) { - const age = Date.now() - job.timestamp; - - const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m` - : age > 10000 ? `${Math.floor(age / 1000)}s` - : `${age}ms`; - - // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする - const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts ? job.opts.attempts : 0; - - return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; -} diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts index e2720b4fe0..600ce0828f 100644 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts @@ -9,7 +9,7 @@ import { deepClone } from '@/misc/clone.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class AggregateRetentionProcessorService { @@ -32,7 +32,7 @@ export class AggregateRetentionProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(): Promise<void> { this.logger.info('Aggregating retention...'); const now = new Date(); @@ -62,7 +62,6 @@ export class AggregateRetentionProcessorService { } catch (err) { if (isDuplicateKeyValueError(err)) { this.logger.succ('Skip because it has already been processed by another worker.'); - done(); return; } throw err; @@ -88,6 +87,5 @@ export class AggregateRetentionProcessorService { } this.logger.succ('Retention aggregated.'); - done(); } } diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index 2476d71a5e..c4ee212bab 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -7,7 +7,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { UserMutingService } from '@/core/UserMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class CheckExpiredMutingsProcessorService { @@ -27,7 +27,7 @@ export class CheckExpiredMutingsProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(): Promise<void> { this.logger.info('Checking expired mutings...'); const expired = await this.mutingsRepository.createQueryBuilder('muting') @@ -41,6 +41,5 @@ export class CheckExpiredMutingsProcessorService { } this.logger.succ('All expired mutings checked.'); - done(); } } diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts index b458167042..22d7c1b4fb 100644 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -16,7 +16,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class CleanChartsProcessorService { @@ -45,7 +45,7 @@ export class CleanChartsProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(): Promise<void> { this.logger.info('Clean charts...'); await Promise.all([ @@ -64,6 +64,5 @@ export class CleanChartsProcessorService { ]); this.logger.succ('All charts successfully cleaned.'); - done(); } } diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 1936e8df23..cefa6da5e9 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -7,7 +7,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class CleanProcessorService { @@ -36,7 +36,7 @@ export class CleanProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(): Promise<void> { this.logger.info('Cleaning...'); this.userIpsRepository.delete({ @@ -72,6 +72,5 @@ export class CleanProcessorService { } this.logger.succ('Cleaned.'); - done(); } } diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index 5a33c27188..c54bf59ae4 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -5,9 +5,9 @@ import type { DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; import { bindThis } from '@/decorators.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; @Injectable() export class CleanRemoteFilesProcessorService { @@ -27,7 +27,7 @@ export class CleanRemoteFilesProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(job: Bull.Job<Record<string, unknown>>): Promise<void> { this.logger.info('Deleting cached remote files...'); let deletedCount = 0; @@ -47,7 +47,7 @@ export class CleanRemoteFilesProcessorService { }); if (files.length === 0) { - job.progress(100); + job.updateProgress(100); break; } @@ -62,10 +62,9 @@ export class CleanRemoteFilesProcessorService { isLink: false, }); - job.progress(deletedCount / total); + job.updateProgress(deletedCount / total); } this.logger.succ('All cached remote files has been deleted.'); - done(); } } diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index e36a78de6a..39dd801af0 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -8,10 +8,10 @@ import { DriveService } from '@/core/DriveService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Note } from '@/models/entities/Note.js'; import { EmailService } from '@/core/EmailService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class DeleteAccountProcessorService { diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index 604497cf54..6772c5dc76 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -5,10 +5,10 @@ import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class DeleteDriveFilesProcessorService { @@ -31,12 +31,11 @@ export class DeleteDriveFilesProcessorService { } @bindThis - public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> { this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -56,7 +55,7 @@ export class DeleteDriveFilesProcessorService { }); if (files.length === 0) { - job.progress(100); + job.updateProgress(100); break; } @@ -71,10 +70,9 @@ export class DeleteDriveFilesProcessorService { userId: user.id, }); - job.progress(deletedCount / total); + job.updateProgress(deletedCount / total); } this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); - done(); } } diff --git a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts index 2fb2f56f8d..edf87bd921 100644 --- a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts @@ -3,10 +3,10 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { ObjectStorageFileJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class DeleteFileProcessorService { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index f293bd4d7e..406e9df850 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, InstancesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -16,7 +17,6 @@ import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; import type { DeliverJobData } from '../types.js'; @Injectable() @@ -121,15 +121,13 @@ export class DeliverProcessorService { isSuspended: true, }); }); - return `${host} is gone`; + throw new Bull.UnrecoverableError(`${host} is gone`); } - // HTTPステータスコード4xxはクライアントエラーであり、それはつまり - // 何回再送しても成功することはないということなのでエラーにはしないでおく - return `${res.statusCode} ${res.statusMessage}`; + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); } // 5xx etc. - throw `${res.statusCode} ${res.statusMessage}`; + throw new Error(`${res.statusCode} ${res.statusMessage}`); } else { // DNS error, socket error, timeout ... throw res; diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts index 501ed4090a..21501592f2 100644 --- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts +++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts @@ -6,7 +6,7 @@ import type Logger from '@/logger.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { EndedPollNotificationJobData } from '../types.js'; @Injectable() @@ -30,10 +30,9 @@ export class EndedPollNotificationProcessorService { } @bindThis - public async process(job: Bull.Job<EndedPollNotificationJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<EndedPollNotificationJobData>): Promise<void> { const note = await this.notesRepository.findOneBy({ id: job.data.noteId }); if (note == null || !note.hasPoll) { - done(); return; } @@ -51,7 +50,5 @@ export class EndedPollNotificationProcessorService { noteId: note.id, }); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 894903e79b..ac52325c8d 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -12,7 +12,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { DBExportAntennasData } from '../types.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class ExportAntennasProcessorService { @@ -39,10 +39,9 @@ export class ExportAntennasProcessorService { } @bindThis - public async process(job: Bull.Job<DBExportAntennasData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DBExportAntennasData>): Promise<void> { const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } const [path, cleanup] = await createTemp(); @@ -96,7 +95,6 @@ export class ExportAntennasProcessorService { this.logger.succ('Exported to: ' + driveFile.id); } finally { cleanup(); - done(); } } } diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index c7b54070d6..eb758e162d 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -9,10 +9,10 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportBlockingProcessorService { @@ -36,12 +36,11 @@ export class ExportBlockingProcessorService { } @bindThis - public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> { this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -69,7 +68,7 @@ export class ExportBlockingProcessorService { }); if (blockings.length === 0) { - job.progress(100); + job.updateProgress(100); break; } @@ -99,7 +98,7 @@ export class ExportBlockingProcessorService { blockerId: user.id, }); - job.progress(exportedCount / total); + job.updateProgress(exportedCount / total); } stream.end(); @@ -112,7 +111,5 @@ export class ExportBlockingProcessorService { } finally { cleanup(); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index b50f373ef8..3203d9f3e5 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -13,7 +13,7 @@ import { createTemp, createTempDir } from '@/misc/create-temp.js'; import { DownloadService } from '@/core/DownloadService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class ExportCustomEmojisProcessorService { @@ -37,12 +37,11 @@ export class ExportCustomEmojisProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise<void> { + public async process(job: Bull.Job): Promise<void> { this.logger.info('Exporting custom emojis ...'); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -117,24 +116,26 @@ export class ExportCustomEmojisProcessorService { metaStream.end(); // Create archive - const [archivePath, archiveCleanup] = await createTemp(); - const archiveStream = fs.createWriteStream(archivePath); - const archive = archiver('zip', { - zlib: { level: 0 }, - }); - archiveStream.on('close', async () => { - this.logger.succ(`Exported to: ${archivePath}`); + await new Promise<void>(async (resolve) => { + const [archivePath, archiveCleanup] = await createTemp(); + const archiveStream = fs.createWriteStream(archivePath); + const archive = archiver('zip', { + zlib: { level: 0 }, + }); + archiveStream.on('close', async () => { + this.logger.succ(`Exported to: ${archivePath}`); - const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; - const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); + const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; + const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); - this.logger.succ(`Exported to: ${driveFile.id}`); - cleanup(); - archiveCleanup(); - done(); + this.logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + archiveCleanup(); + resolve(); + }); + archive.pipe(archiveStream); + archive.directory(path, false); + archive.finalize(); }); - archive.pipe(archiveStream); - archive.directory(path, false); - archive.finalize(); } } diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index f2f2383a88..76c38a6b86 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -12,7 +12,7 @@ import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @Injectable() @@ -42,12 +42,11 @@ export class ExportFavoritesProcessorService { } @bindThis - public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> { this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -91,7 +90,7 @@ export class ExportFavoritesProcessorService { }) as (NoteFavorite & { note: Note & { user: User } })[]; if (favorites.length === 0) { - job.progress(100); + job.updateProgress(100); break; } @@ -112,7 +111,7 @@ export class ExportFavoritesProcessorService { userId: user.id, }); - job.progress(exportedFavoritesCount / total); + job.updateProgress(exportedFavoritesCount / total); } await write(']'); @@ -127,8 +126,6 @@ export class ExportFavoritesProcessorService { } finally { cleanup(); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index fa9c1ac1ea..8726cb1402 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { Following } from '@/models/entities/Following.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbExportFollowingData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportFollowingProcessorService { @@ -40,12 +40,11 @@ export class ExportFollowingProcessorService { } @bindThis - public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbExportFollowingData>): Promise<void> { this.logger.info(`Exporting following of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -116,7 +115,5 @@ export class ExportFollowingProcessorService { } finally { cleanup(); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index b14bf5f5b1..0f11a9e843 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -9,10 +9,10 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportMutingProcessorService { @@ -39,12 +39,11 @@ export class ExportMutingProcessorService { } @bindThis - public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> { this.logger.info(`Exporting muting of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -73,7 +72,7 @@ export class ExportMutingProcessorService { }); if (mutes.length === 0) { - job.progress(100); + job.updateProgress(100); break; } @@ -103,7 +102,7 @@ export class ExportMutingProcessorService { muterId: user.id, }); - job.progress(exportedCount / total); + job.updateProgress(exportedCount / total); } stream.end(); @@ -116,7 +115,5 @@ export class ExportMutingProcessorService { } finally { cleanup(); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index e4f12ad101..24fb331883 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -12,7 +12,7 @@ import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @Injectable() @@ -39,12 +39,11 @@ export class ExportNotesProcessorService { } @bindThis - public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> { this.logger.info(`Exporting notes of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -87,7 +86,7 @@ export class ExportNotesProcessorService { }) as Note[]; if (notes.length === 0) { - job.progress(100); + job.updateProgress(100); break; } @@ -108,7 +107,7 @@ export class ExportNotesProcessorService { userId: user.id, }); - job.progress(exportedNotesCount / total); + job.updateProgress(exportedNotesCount / total); } await write(']'); @@ -123,8 +122,6 @@ export class ExportNotesProcessorService { } finally { cleanup(); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index 54bde44044..ec63358053 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -9,10 +9,10 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportUserListsProcessorService { @@ -39,12 +39,11 @@ export class ExportUserListsProcessorService { } @bindThis - public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> { this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -92,7 +91,5 @@ export class ExportUserListsProcessorService { } finally { cleanup(); } - - done(); } } diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index d06131b8c8..575cad69d5 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import { DBAntennaImportJobData } from '../types.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; const validate = new Ajv().compile({ type: 'object', @@ -59,7 +59,7 @@ export class ImportAntennasProcessorService { } @bindThis - public async process(job: Bull.Job<DBAntennaImportJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DBAntennaImportJobData>): Promise<void> { const now = new Date(); try { for (const antenna of job.data.antenna) { @@ -89,8 +89,6 @@ export class ImportAntennasProcessorService { } } catch (err: any) { this.logger.error(err); - } finally { - done(); } } } diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts index 3f075b02d2..2f1a9e5b03 100644 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -7,11 +7,11 @@ import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; -import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; import { QueueService } from '@/core/QueueService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; @Injectable() export class ImportBlockingProcessorService { @@ -34,12 +34,11 @@ export class ImportBlockingProcessorService { } @bindThis - public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> { this.logger.info(`Importing blocking of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -47,7 +46,6 @@ export class ImportBlockingProcessorService { id: job.data.fileId, }); if (file == null) { - done(); return; } @@ -56,7 +54,6 @@ export class ImportBlockingProcessorService { this.queueService.createImportBlockingToDbJob({ id: user.id }, targets); this.logger.succ('Import jobs created'); - done(); } @bindThis @@ -85,7 +82,7 @@ export class ImportBlockingProcessorService { } if (target == null) { - throw `Unable to resolve user: @${username}@${host}`; + throw new Error(`Unable to resolve user: @${username}@${host}`); } // skip myself diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index cf78d8330c..d862567871 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -12,7 +12,7 @@ import { DriveService } from '@/core/DriveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; // TODO: 名前衝突時の動作を選べるようにする @@ -45,14 +45,13 @@ export class ImportCustomEmojisProcessorService { } @bindThis - public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> { this.logger.info('Importing custom emojis ...'); const file = await this.driveFilesRepository.findOneBy({ id: job.data.fileId, }); if (file == null) { - done(); return; } @@ -107,13 +106,15 @@ export class ImportCustomEmojisProcessorService { aliases: emojiInfo.aliases, driveFile, license: emojiInfo.license, + isSensitive: emojiInfo.isSensitive, + localOnly: emojiInfo.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], }); } cleanup(); this.logger.succ('Imported'); - done(); }); unzipStream.pipe(extractor); this.logger.succ(`Unzipping to ${outputPath}`); diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts index aa5cf12c50..15bee9672e 100644 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -7,11 +7,11 @@ import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; -import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; import { QueueService } from '@/core/QueueService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; @Injectable() export class ImportFollowingProcessorService { @@ -34,12 +34,11 @@ export class ImportFollowingProcessorService { } @bindThis - public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> { this.logger.info(`Importing following of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -47,7 +46,6 @@ export class ImportFollowingProcessorService { id: job.data.fileId, }); if (file == null) { - done(); return; } @@ -56,7 +54,6 @@ export class ImportFollowingProcessorService { this.queueService.createImportFollowingToDbJob({ id: user.id }, targets); this.logger.succ('Import jobs created'); - done(); } @bindThis @@ -85,7 +82,7 @@ export class ImportFollowingProcessorService { } if (target == null) { - throw `Unable to resolve user: @${username}@${host}`; + throw new Error(`Unable to resolve user: @${username}@${host}`); } // skip myself diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts index 379994ee79..723935cd31 100644 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -9,10 +9,10 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { UserMutingService } from '@/core/UserMutingService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ImportMutingProcessorService { @@ -38,12 +38,11 @@ export class ImportMutingProcessorService { } @bindThis - public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> { this.logger.info(`Importing muting of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -51,7 +50,6 @@ export class ImportMutingProcessorService { id: job.data.fileId, }); if (file == null) { - done(); return; } @@ -83,7 +81,7 @@ export class ImportMutingProcessorService { } if (target == null) { - throw `cannot resolve user: @${username}@${host}`; + throw new Error(`cannot resolve user: @${username}@${host}`); } // skip myself @@ -98,6 +96,5 @@ export class ImportMutingProcessorService { } this.logger.succ('Imported'); - done(); } } diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index c423863410..824ee8157a 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -12,7 +12,7 @@ import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @Injectable() @@ -46,12 +46,11 @@ export class ImportUserListsProcessorService { } @bindThis - public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> { + public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> { this.logger.info(`Importing user lists of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { - done(); return; } @@ -59,7 +58,6 @@ export class ImportUserListsProcessorService { id: job.data.fileId, }); if (file == null) { - done(); return; } @@ -109,6 +107,5 @@ export class ImportUserListsProcessorService { } this.logger.succ('Imported'); - done(); } } diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index ab8b1e9e22..ce1d7aaa1b 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -1,8 +1,8 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import httpSignature from '@peertube/http-signature'; +import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; -import type { InstancesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; @@ -23,10 +23,8 @@ import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; import type { InboxJobData } from '../types.js'; -// ユーザーのinboxにアクティビティが届いた時の処理 @Injectable() export class InboxProcessorService { private logger: Logger; @@ -35,12 +33,6 @@ export class InboxProcessorService { @Inject(DI.config) private config: Config, - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - private utilityService: UtilityService, private metaService: MetaService, private apInboxService: ApInboxService, @@ -93,24 +85,24 @@ export class InboxProcessorService { try { authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); } catch (err) { - // 対象が4xxならスキップ + // 対象が4xxならスキップ if (err instanceof StatusError) { if (err.isClientError) { - return `skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`; + throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); } - throw `Error in actor ${activity.actor} - ${err.statusCode ?? err}`; + throw new Error(`Error in actor ${activity.actor} - ${err.statusCode ?? err}`); } } } // それでもわからなければ終了 if (authUser == null) { - return 'skip: failed to resolve user'; + throw new Bull.UnrecoverableError('skip: failed to resolve user'); } // publicKey がなくても終了 if (authUser.key == null) { - return 'skip: failed to resolve user publicKey'; + throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); } // HTTP-Signatureの検証 @@ -118,10 +110,10 @@ export class InboxProcessorService { // また、signatureのsignerは、activity.actorと一致する必要がある if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { - // 一致しなくても、でもLD-Signatureがありそうならそっちも見る + // 一致しなくても、でもLD-Signatureがありそうならそっちも見る if (activity.signature) { if (activity.signature.type !== 'RsaSignature2017') { - return `skip: unsupported LD-signature type ${activity.signature.type}`; + throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${activity.signature.type}`); } // activity.signature.creator: https://example.oom/users/user#main-key @@ -134,32 +126,32 @@ export class InboxProcessorService { // keyIdからLD-Signatureのユーザーを取得 authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator); if (authUser == null) { - return 'skip: LD-Signatureのユーザーが取得できませんでした'; + throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); } if (authUser.key == null) { - return 'skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'; + throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); } // LD-Signature検証 const ldSignature = this.ldSignatureService.use(); const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); if (!verified) { - return 'skip: LD-Signatureの検証に失敗しました'; + throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); } // もう一度actorチェック if (authUser.user.uri !== activity.actor) { - return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; + throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); } // ブロックしてたら中断 const ldHost = this.utilityService.extractDbHost(authUser.user.uri); if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { - return `Blocked request: ${ldHost}`; + throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); } } else { - return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`; + throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); } } @@ -168,7 +160,7 @@ export class InboxProcessorService { const signerHost = this.utilityService.extractDbHost(authUser.user.uri!); const activityIdHost = this.utilityService.extractDbHost(activity.id); if (signerHost !== activityIdHost) { - return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; + throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`); } } diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts index ff454df455..722260d948 100644 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts index e5840f3da8..eab8e1e68d 100644 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -15,7 +15,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class ResyncChartsProcessorService { @@ -43,7 +43,7 @@ export class ResyncChartsProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(): Promise<void> { this.logger.info('Resync charts...'); // TODO: ユーザーごとのチャートも更新する @@ -55,6 +55,5 @@ export class ResyncChartsProcessorService { ]); this.logger.succ('All charts successfully resynced.'); - done(); } } diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts index 7ff84c15a5..f1696bf567 100644 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -16,7 +16,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; +import type * as Bull from 'bullmq'; @Injectable() export class TickChartsProcessorService { @@ -45,7 +45,7 @@ export class TickChartsProcessorService { } @bindThis - public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> { + public async process(): Promise<void> { this.logger.info('Tick charts...'); await Promise.all([ @@ -64,6 +64,5 @@ export class TickChartsProcessorService { ]); this.logger.succ('All charts successfully ticked.'); - done(); } } diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts index 84a5c21c49..8b40c16749 100644 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { WebhooksRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -7,7 +8,6 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type Bull from 'bull'; import type { WebhookDeliverJobData } from '../types.js'; @Injectable() @@ -66,11 +66,11 @@ export class WebhookDeliverProcessorService { if (res instanceof StatusError) { // 4xx if (res.isClientError) { - return `${res.statusCode} ${res.statusMessage}`; + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); } // 5xx etc. - throw `${res.statusCode} ${res.statusMessage}`; + throw new Error(`${res.statusCode} ${res.statusMessage}`); } else { // DNS error, socket error, timeout ... throw res; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index e675d9cf1b..455acd1e47 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -585,7 +585,7 @@ export class ActivityPubServerService { name: request.params.emoji, }); - if (emoji == null) { + if (emoji == null || emoji.localOnly) { reply.code(404); return; } diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 9257fee13e..c3d45e4ad6 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -194,7 +194,7 @@ export class ServerService implements OnApplicationShutdown { fastify.register(this.clientServerService.createServer); - this.streamingApiServerService.attachStreamingApi(fastify.server); + this.streamingApiServerService.attach(fastify.server); fastify.server.on('error', err => { switch ((err as any).code) { @@ -222,7 +222,14 @@ export class ServerService implements OnApplicationShutdown { await fastify.ready(); } - async onApplicationShutdown(signal: string): Promise<void> { + @bindThis + public async dispose(): Promise<void> { + await this.streamingApiServerService.detach(); await this.#fastify.close(); } + + @bindThis + async onApplicationShutdown(signal: string): Promise<void> { + await this.dispose(); + } } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index e3483c82c6..dad1a4132a 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -359,7 +359,12 @@ export class ApiCallService implements OnApplicationShutdown { } @bindThis - public onApplicationShutdown(signal?: string | undefined) { + public dispose(): void { clearInterval(this.userIpHistoriesClearIntervalId); } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 6548c475b2..e23591d876 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -36,7 +36,7 @@ export class AuthenticateService { } @bindThis - public async authenticate(token: string | null | undefined): Promise<[LocalUser | null | undefined, AccessToken | null | undefined]> { + public async authenticate(token: string | null | undefined): Promise<[LocalUser | null, AccessToken | null]> { if (token == null) { return [null, null]; } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ee1aae5b6c..1e32e9988d 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -321,6 +321,9 @@ import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; +import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; +import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; +import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_reactions from './endpoints/users/reactions.js'; @@ -659,6 +662,9 @@ const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass: const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default }; const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default }; const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; +const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; +const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; +const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default }; @@ -1001,6 +1007,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_push, $users_lists_show, $users_lists_update, + $users_lists_favorite, + $users_lists_unfavorite, + $users_lists_create_from_public, $users_notes, $users_pages, $users_reactions, @@ -1335,6 +1344,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_push, $users_lists_show, $users_lists_update, + $users_lists_favorite, + $users_lists_unfavorite, + $users_lists_create_from_public, $users_notes, $users_pages, $users_reactions, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 258e8de034..893dfe956e 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -1,23 +1,27 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import * as websocket from 'websocket'; +import * as WebSocket from 'ws'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; +import type { UsersRepository, AccessToken } from '@/models/index.js'; import type { Config } from '@/config.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; -import { AuthenticateService } from './AuthenticateService.js'; +import { LocalUser } from '@/models/entities/User'; +import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; -import type { ParsedUrlQuery } from 'querystring'; import type * as http from 'node:http'; @Injectable() export class StreamingApiServerService { + #wss: WebSocket.WebSocketServer; + #connections = new Map<WebSocket.WebSocket, number>(); + #cleanConnectionsIntervalId: NodeJS.Timeout | null = null; + constructor( @Inject(DI.config) private config: Config, @@ -28,24 +32,6 @@ export class StreamingApiServerService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - private cacheService: CacheService, private noteReadService: NoteReadService, private authenticateService: AuthenticateService, @@ -55,25 +41,65 @@ export class StreamingApiServerService { } @bindThis - public attachStreamingApi(server: http.Server) { - // Init websocket server - const ws = new websocket.server({ - httpServer: server, + public attach(server: http.Server): void { + this.#wss = new WebSocket.WebSocketServer({ + noServer: true, }); - ws.on('request', async (request) => { - const q = request.resourceURL.query as ParsedUrlQuery; + server.on('upgrade', async (request, socket, head) => { + if (request.url == null) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + socket.destroy(); + return; + } + + const q = new URL(request.url, `http://${request.headers.host}`).searchParams; + + let user: LocalUser | null = null; + let app: AccessToken | null = null; - // TODO: トークンが間違ってるなどしてauthenticateに失敗したら - // コネクション切断するなりエラーメッセージ返すなりする - // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) - const [user, miapp] = await this.authenticateService.authenticate(q.i as string); + try { + [user, app] = await this.authenticateService.authenticate(q.get('i')); + } catch (e) { + if (e instanceof AuthenticationError) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + } else { + socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); + } + socket.destroy(); + return; + } if (user?.isSuspended) { - request.reject(400); + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); return; } + const stream = new MainStreamConnection( + this.channelsService, + this.noteReadService, + this.notificationService, + this.cacheService, + user, app, + ); + + await stream.init(); + + this.#wss.handleUpgrade(request, socket, head, (ws) => { + this.#wss.emit('connection', ws, request, { + stream, user, app, + }); + }); + }); + + this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: { + stream: MainStreamConnection, + user: LocalUser | null; + app: AccessToken | null + }) => { + const { stream, user, app } = ctx; + const ev = new EventEmitter(); async function onRedisMessage(_: string, data: string): Promise<void> { @@ -83,21 +109,11 @@ export class StreamingApiServerService { this.redisForSub.on('message', onRedisMessage); - const main = new MainStreamConnection( - this.channelsService, - this.noteReadService, - this.notificationService, - this.cacheService, - ev, user, miapp, - ); + await stream.listen(ev, connection); - await main.init(); + this.#connections.set(connection, Date.now()); - const connection = request.accept(); - - main.init2(connection); - - const intervalId = user ? setInterval(() => { + const userUpdateIntervalId = user ? setInterval(() => { this.usersRepository.update(user.id, { lastActiveDate: new Date(), }); @@ -110,16 +126,38 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); - main.dispose(); + stream.dispose(); this.redisForSub.off('message', onRedisMessage); - if (intervalId) clearInterval(intervalId); + if (userUpdateIntervalId) clearInterval(userUpdateIntervalId); }); connection.on('message', async (data) => { - if (data.type === 'utf8' && data.utf8Data === 'ping') { + this.#connections.set(connection, Date.now()); + if (data.toString() === 'ping') { connection.send('pong'); } }); }); + + this.#cleanConnectionsIntervalId = setInterval(() => { + const now = Date.now(); + for (const [connection, lastActive] of this.#connections.entries()) { + if (now - lastActive > 1000 * 60 * 5) { + connection.terminate(); + this.#connections.delete(connection); + } + } + }, 1000 * 60 * 5); + } + + @bindThis + public detach(): Promise<void> { + if (this.#cleanConnectionsIntervalId) { + clearInterval(this.#cleanConnectionsIntervalId); + this.#cleanConnectionsIntervalId = null; + } + return new Promise((resolve) => { + this.#wss.close(() => resolve()); + }); } } diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 09bd7cbff4..7e678a6404 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -320,6 +320,9 @@ import * as ep___users_lists_list from './endpoints/users/lists/list.js'; import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; +import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; +import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; +import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; @@ -656,7 +659,10 @@ const eps = [ ['users/lists/pull', ep___users_lists_pull], ['users/lists/push', ep___users_lists_push], ['users/lists/show', ep___users_lists_show], + ['users/lists/favorite', ep___users_lists_favorite], + ['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/update', ep___users_lists_update], + ['users/lists/create-from-public', ep___users_lists_create_from_public], ['users/notes', ep___users_notes], ['users/pages', ep___users_pages], ['users/reactions', ep___users_reactions], diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 2393c2441c..12db1f78fb 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -25,7 +25,7 @@ export const paramDef = { id: { type: 'string', format: 'misskey:id' }, title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, - imageUrl: { type: 'string', nullable: true, minLength: 1 }, + imageUrl: { type: 'string', nullable: true, minLength: 0 }, }, required: ['id', 'title', 'text', 'imageUrl'], } as const; @@ -46,7 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { updatedAt: new Date(), title: ps.title, text: ps.text, - imageUrl: ps.imageUrl, + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ + imageUrl: ps.imageUrl || null, }); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 2fb3e489e7..509224e9c3 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -25,9 +25,24 @@ export const meta = { export const paramDef = { type: 'object', properties: { + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, fileId: { type: 'string', format: 'misskey:id' }, + category: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the category.', + }, + aliases: { type: 'array', items: { + type: 'string', + } }, + license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { + type: 'string', + } }, }, - required: ['fileId'], + required: ['name', 'fileId'], } as const; // TODO: ロジックをサービスに切り出す @@ -45,18 +60,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { super(meta, paramDef, async (ps, me) => { const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); - const name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; - const emoji = await this.customEmojiService.add({ driveFile, - name, - category: null, - aliases: [], + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], host: null, - license: null, + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], }); this.moderationLogService.insertModerationLog(me, 'addEmoji', { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index f63348b60b..fb22bdc477 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,6 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import type { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -15,6 +17,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d', + }, sameNameEmojiExists: { message: 'Emoji that have same name already exists.', code: 'SAME_NAME_EMOJI_EXISTS', @@ -28,6 +35,7 @@ export const paramDef = { properties: { id: { type: 'string', format: 'misskey:id' }, name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, + fileId: { type: 'string', format: 'misskey:id' }, category: { type: 'string', nullable: true, @@ -37,6 +45,11 @@ export const paramDef = { type: 'string', } }, license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { + type: 'string', + } }, }, required: ['id', 'name', 'aliases'], } as const; @@ -45,14 +58,28 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { + let driveFile; + + if (ps.fileId) { + driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); + } + await this.customEmojiService.update(ps.id, { + driveFile, name: ps.name, category: ps.category ?? null, aliases: ps.aliases, license: ps.license ?? null, + isSensitive: ps.isSensitive, + localOnly: ps.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, }); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index f12738bd3a..f2d4aa8996 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -62,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { super(meta, paramDef, async (ps, me) => { try { - if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; + if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only'); } catch { throw new ApiError(meta.errors.invalidUrl); } diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index dca0f443b7..e756a9b510 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -113,6 +113,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { } this.antennasRepository.update(antenna.id, { + isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts index cb2e661bfb..05842460cf 100644 --- a/packages/backend/src/server/api/endpoints/auth/accept.ts +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -55,7 +55,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { throw new ApiError(meta.errors.noSuchSession); } - // Generate access token const accessToken = secureRndstr(32, true); // Fetch exist access token @@ -65,7 +64,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { }); if (exist == null) { - // Lookup app const app = await this.appsRepository.findOneByOrFail({ id: session.appId }); // Generate Hash @@ -75,7 +73,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const now = new Date(); - // Insert access token doc await this.accessTokensRepository.insert({ id: this.idService.genId(), createdAt: now, 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 ad33398da6..e8985a9cd8 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 @@ -1,6 +1,6 @@ import { promisify } from 'node:util'; import bcrypt from 'bcryptjs'; -import * as cbor from 'cbor'; +import cbor from 'cbor'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 3361e5a4d3..48fb03a8af 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -26,7 +26,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { super(meta, paramDef, async (ps, me) => { const query = this.accessTokensRepository.createQueryBuilder('token') - .where('token.userId = :userId', { userId: me.id }); + .where('token.userId = :userId', { userId: me.id }) + .leftJoinAndSelect('token.app', 'app'); switch (ps.sort) { case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; @@ -40,7 +41,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { return await Promise.all(tokens.map(token => ({ id: token.id, - name: token.name, + name: token.name ?? token.app?.name, createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, permission: token.permission, diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index e141be764a..f5662f4a0e 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -91,18 +91,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { 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 limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const notificationsRes = await this.redisClient.xrevrange( `notificationTimeline:${me.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - '-', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', 'COUNT', limit); if (notificationsRes.length === 0) { return []; } - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[]; + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as Notification[]; if (includeTypes && includeTypes.length > 0) { notifications = notifications.filter(notification => includeTypes.includes(notification.type)); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 74be00a8b8..8f5e6177c2 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -141,13 +141,12 @@ export const paramDef = { preventAiLearning: { type: 'boolean' }, isBot: { type: 'boolean' }, isCat: { type: 'boolean' }, - showTimelineReplies: { type: 'boolean' }, injectFeaturedNote: { type: 'boolean' }, receiveAnnouncementEmail: { type: 'boolean' }, alwaysMarkNsfw: { type: 'boolean' }, autoSensitive: { type: 'boolean' }, ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, - pinnedPageId: { type: 'string', format: 'misskey:id' }, + pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, mutedWords: { type: 'array' }, mutedInstances: { type: 'array', items: { type: 'string', @@ -239,7 +238,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; - if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 584ea07c3b..53d724a9dd 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -1,5 +1,6 @@ import { IsNull, LessThanOrEqual, MoreThan } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; +import * as JSON5 from 'json5'; import type { AdsRepository, UsersRepository } from '@/models/index.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -292,8 +293,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { backgroundImageUrl: instance.backgroundImageUrl, logoImageUrl: instance.logoImageUrl, maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, - defaultLightTheme: instance.defaultLightTheme, - defaultDarkTheme: instance.defaultDarkTheme, + // クライアントの手間を減らすためあらかじめJSONに変換しておく + defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null, + defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null, ads: ads.map(ad => ({ id: ad.id, url: ad.url, diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 3f7f2cdece..96be5ed844 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -99,7 +99,7 @@ export const paramDef = { } }, cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, - reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index c11c1eac40..88c1ca7f58 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -34,11 +34,8 @@ export const meta = { export const paramDef = { type: 'object', properties: { - withFiles: { - type: 'boolean', - default: false, - description: 'Only show notes that have attached files.', - }, + withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -78,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - this.queryService.generateRepliesQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); if (me) { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 89abd91c7e..7a3581e6e4 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -46,11 +46,8 @@ export const paramDef = { includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, - withFiles: { - type: 'boolean', - default: false, - description: 'Only show notes that have attached files.', - }, + withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, }, required: [], } as const; @@ -98,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .setParameters(followingQuery.getParameters()); this.queryService.generateChannelQuery(query, me); - this.queryService.generateRepliesQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index afdafc7c55..2ee549232c 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -36,11 +36,8 @@ export const meta = { export const paramDef = { type: 'object', properties: { - withFiles: { - type: 'boolean', - default: false, - description: 'Only show notes that have attached files.', - }, + withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, fileType: { type: 'array', items: { type: 'string', } }, @@ -86,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateChannelQuery(query, me); - this.queryService.generateRepliesQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 2956bf1cbd..742df0ca95 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -82,14 +82,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { try { if (ps.tag) { - if (!safeForSql(normalizeForSearch(ps.tag))) throw 'Injection'; + if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); } else { query.andWhere(new Brackets(qb => { for (const tags of ps.query!) { qb.orWhere(new Brackets(qb => { for (const tag of tags) { - if (!safeForSql(normalizeForSearch(tag))) throw 'Injection'; + if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection'); qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); } })); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index c6ee1e5c2b..e1f286439b 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -35,11 +35,8 @@ export const paramDef = { includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, - withFiles: { - type: 'boolean', - default: false, - description: 'Only show notes that have attached files.', - }, + withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, }, required: [], } as const; @@ -84,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { } this.queryService.generateChannelQuery(query, me); - this.queryService.generateRepliesQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 4ced6d3ff1..1d4825f812 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { private redisClient: Redis.Redis, ) { super(meta, paramDef, async (ps, me) => { - if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); await redisClient.flushdb(); await resetDb(this.db); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 6202c740f1..42e36cb04a 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -93,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .andWhere('(note.visibility = \'public\')') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts new file mode 100644 index 0000000000..8591e4ab96 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -0,0 +1,148 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserListService } from '@/core/UserListService.js'; + +export const meta = { + requireCredential: true, + prohibitMoved: true, + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserList', + }, + + errors: { + tooManyUserLists: { + message: 'You cannot create user list any more.', + code: 'TOO_MANY_USERLISTS', + id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f', + }, + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '9292f798-6175-4f7d-93f4-b6742279667d', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f', + }, + + alreadyAdded: { + message: 'That user has already been added to that list.', + code: 'ALREADY_ADDED', + id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615', + }, + + youHaveBeenBlocked: { + message: 'You cannot push this user because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'a2497f2a-2389-439c-8626-5298540530f4', + }, + + tooManyUsers: { + message: 'You can not push users any more.', + code: 'TOO_MANY_USERS', + id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + listId: { type: 'string', format: 'misskey:id' }, + }, + required: ['name', 'listId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userListService: UserListService, + private userListEntityService: UserListEntityService, + private idService: IdService, + private getterService: GetterService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const list = await this.userListsRepository.findOneBy({ + id: ps.listId, + isPublic: true, + }); + if (list === null) throw new ApiError(meta.errors.noSuchList); + const currentCount = await this.userListsRepository.countBy({ + userId: me.id, + }); + if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + throw new ApiError(meta.errors.tooManyUserLists); + } + + const userList = await this.userListsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + } as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); + + const users = (await this.userListJoiningsRepository.findBy({ + userListId: ps.listId, + })).map(x => x.userId); + + for (const user of users) { + const currentUser = await this.getterService.getUser(user).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + if (currentUser.id !== me.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: currentUser.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const exist = await this.userListJoiningsRepository.findOneBy({ + userListId: userList.id, + userId: currentUser.id, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + try { + await this.userListService.push(currentUser, userList, me); + } catch (err) { + if (err instanceof UserListService.TooManyUsersError) { + throw new ApiError(meta.errors.tooManyUsers); + } + throw err; + } + } + return await this.userListEntityService.pack(userList); + }); + } +} + diff --git a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts new file mode 100644 index 0000000000..263852fde1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '@/server/api/error.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + requireCredential: true, + errors: { + noSuchList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe', + }, + + alreadyFavorited: { + message: 'The list has already been favorited.', + code: 'ALREADY_FAVORITED', + id: '6425bba0-985b-461e-af1b-518070e72081', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + listId: { type: 'string', format: 'misskey:id' }, + }, + required: ['listId'], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor ( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListFavoritesRepository) + private userListFavoritesRepository: UserListFavoritesRepository, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + isPublic: true, + }); + + if (userList === null) { + throw new ApiError(meta.errors.noSuchList); + } + + const exist = await this.userListFavoritesRepository.findOneBy({ + userId: me.id, + userListId: ps.listId, + }); + + if (exist !== null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.userListFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + userListId: ps.listId, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts index 2104c4377d..eab29944b2 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/list.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -1,13 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/index.js'; +import type { UserListsRepository, UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; export const meta = { tags: ['lists', 'account'], - requireCredential: true, + requireCredential: false, kind: 'read:account', @@ -22,26 +23,58 @@ export const meta = { ref: 'UserList', }, }, + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e', + }, + remoteUser: { + message: 'Not allowed to load the remote user\'s list', + code: 'REMOTE_USER_NOT_ALLOWED', + id: '53858f1b-3315-4a01-81b7-db9b48d4b79a', + }, + invalidParam: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: 'ab36de0e-29e9-48cb-9732-d82f1281620d', + }, + }, } as const; export const paramDef = { type: 'object', - properties: {}, + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, required: [], } as const; -// eslint-disable-next-line import/no-default-export -@Injectable() +@Injectable() // eslint-disable-next-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, private userListEntityService: UserListEntityService, ) { super(meta, paramDef, async (ps, me) => { - const userLists = await this.userListsRepository.findBy({ + if (typeof ps.userId !== 'undefined') { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user === null) throw new ApiError(meta.errors.noSuchUser); + if (user.host !== null) throw new ApiError(meta.errors.remoteUser); + } else if (me === null) { + throw new ApiError(meta.errors.invalidParam); + } + + const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? { userId: me.id, + } : { + userId: ps.userId, + isPublic: true, }); return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index 77f9cba808..8077841c8c 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/index.js'; +import type { UserListsRepository, UserListFavoritesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -8,7 +8,7 @@ import { ApiError } from '../../../error.js'; export const meta = { tags: ['lists', 'account'], - requireCredential: true, + requireCredential: false, kind: 'read:account', @@ -33,31 +33,54 @@ export const paramDef = { type: 'object', properties: { listId: { type: 'string', format: 'misskey:id' }, + forPublic: { type: 'boolean', default: false }, }, required: ['listId'], } as const; -// eslint-disable-next-line import/no-default-export -@Injectable() +@Injectable() // eslint-disable-next-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + @Inject(DI.userListFavoritesRepository) + private userListFavoritesRepository: UserListFavoritesRepository, + private userListEntityService: UserListEntityService, ) { super(meta, paramDef, async (ps, me) => { + const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {}; // Fetch the list - const userList = await this.userListsRepository.findOneBy({ + const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { id: ps.listId, userId: me.id, + } : { + id: ps.listId, + isPublic: true, }); if (userList == null) { throw new ApiError(meta.errors.noSuchList); } - return await this.userListEntityService.pack(userList); + if (ps.forPublic && userList.isPublic) { + additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({ + userListId: ps.listId, + }); + if (me !== null) { + additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({ + userId: me.id, + userListId: ps.listId, + }) !== null); + } else { + additionalProperties.isLiked = false; + } + } + return { + ...await this.userListEntityService.pack(userList), + ...additionalProperties, + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts new file mode 100644 index 0000000000..be8e317816 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + requireCredential: true, + errors: { + noSuchList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b', + }, + + notFavorited: { + message: 'You have not favorited the list.', + code: 'ALREADY_FAVORITED', + id: '835c4b27-463d-4cfa-969b-a9058678d465', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + listId: { type: 'string', format: 'misskey:id' }, + }, + required: ['listId'], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor ( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListFavoritesRepository) + private userListFavoritesRepository: UserListFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + isPublic: true, + }); + + if (userList === null) { + throw new ApiError(meta.errors.noSuchList); + } + + const exist = await this.userListFavoritesRepository.findOneBy({ + userListId: ps.listId, + userId: me.id, + }); + + if (exist === null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.userListFavoritesRepository.delete({ id: exist.id }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts index 6453d7d980..b0a95a2f28 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/update.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -34,8 +34,9 @@ export const paramDef = { properties: { listId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, + isPublic: { type: 'boolean' }, }, - required: ['listId', 'name'], + required: ['listId'], } as const; // eslint-disable-next-line import/no-default-export @@ -48,7 +49,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { private userListEntityService: UserListEntityService, ) { super(meta, paramDef, async (ps, me) => { - // Fetch the list const userList = await this.userListsRepository.findOneBy({ id: ps.listId, userId: me.id, @@ -60,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { await this.userListsRepository.update(userList.id, { name: ps.name, + isPublic: ps.isPublic, }); return await this.userListEntityService.pack(userList.id); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 5454836fe1..d3339072c1 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -13,6 +13,7 @@ class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = true; public static requireCredential = false; + private withReplies: boolean; constructor( private metaService: MetaService, @@ -31,6 +32,8 @@ class GlobalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.gtlAvailable) return; + this.withReplies = params.withReplies as boolean; + // Subscribe events this.subscriber.on('notesStream', this.onNote); } @@ -54,7 +57,7 @@ class GlobalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.user!.showTimelineReplies) { + if (note.reply && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index ee874ad81e..1755aa94cf 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -11,6 +11,7 @@ class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = true; public static requireCredential = true; + private withReplies: boolean; constructor( private noteEntityService: NoteEntityService, @@ -24,6 +25,8 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { + this.withReplies = params.withReplies as boolean; + this.subscriber.on('notesStream', this.onNote); } @@ -63,7 +66,7 @@ class HomeTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.user!.showTimelineReplies) { + if (note.reply && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 4f7b4e78b6..5a33e13cf5 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -13,6 +13,7 @@ class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = true; public static requireCredential = true; + private withReplies: boolean; constructor( private metaService: MetaService, @@ -31,6 +32,8 @@ class HybridTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; + this.withReplies = params.withReplies as boolean; + // Subscribe events this.subscriber.on('notesStream', this.onNote); } @@ -75,7 +78,7 @@ class HybridTimelineChannel extends Channel { if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 - if (note.reply && !this.user!.showTimelineReplies) { + if (note.reply && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 09b0005ac1..9ca4db8ced 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -12,6 +12,7 @@ class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; public static shouldShare = true; public static requireCredential = false; + private withReplies: boolean; constructor( private metaService: MetaService, @@ -30,6 +31,8 @@ class LocalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; + this.withReplies = params.withReplies as boolean; + // Subscribe events this.subscriber.on('notesStream', this.onNote); } @@ -54,7 +57,7 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && this.user && !this.user.showTimelineReplies) { + if (note.reply && this.user && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 9d106c8b2f..ab9c1aa0b5 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -5,15 +5,17 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; import { StreamMessages } from '../types.js'; +import { RoleService } from '@/core/RoleService.js'; class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; public static shouldShare = false; public static requireCredential = false; private roleId: string; - + constructor( private noteEntityService: NoteEntityService, + private roleservice: RoleService, id: string, connection: Channel['connection'], @@ -34,6 +36,11 @@ class RoleTimelineChannel extends Channel { if (data.type === 'note') { const note = data.body; + if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { + return; + } + if (note.visibility !== 'public') return; + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する @@ -61,6 +68,7 @@ export class RoleTimelineChannelService { constructor( private noteEntityService: NoteEntityService, + private roleservice: RoleService, ) { } @@ -68,6 +76,7 @@ export class RoleTimelineChannelService { public create(id: string, connection: Channel['connection']): RoleTimelineChannel { return new RoleTimelineChannel( this.noteEntityService, + this.roleservice, id, connection, ); diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index a6f9145952..8b1c2c09c9 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,3 +1,4 @@ +import * as WebSocket from 'ws'; import type { User } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -7,7 +8,6 @@ import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { UserProfile } from '@/models/index.js'; import type { ChannelsService } from './ChannelsService.js'; -import type * as websocket from 'websocket'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; import type { StreamEventEmitter, StreamMessages } from './types.js'; @@ -18,7 +18,7 @@ import type { StreamEventEmitter, StreamMessages } from './types.js'; export default class Connection { public user?: User; public token?: AccessToken; - private wsConnection: websocket.connection; + private wsConnection: WebSocket.WebSocket; public subscriber: StreamEventEmitter; private channels: Channel[] = []; private subscribingNotes: any = {}; @@ -37,11 +37,9 @@ export default class Connection { private notificationService: NotificationService, private cacheService: CacheService, - subscriber: EventEmitter, user: User | null | undefined, token: AccessToken | null | undefined, ) { - this.subscriber = subscriber; if (user) this.user = user; if (token) this.token = token; } @@ -70,12 +68,16 @@ export default class Connection { if (this.user != null) { await this.fetch(); - this.fetchIntervalId = setInterval(this.fetch, 1000 * 10); + if (!this.fetchIntervalId) { + this.fetchIntervalId = setInterval(this.fetch, 1000 * 10); + } } } @bindThis - public async init2(wsConnection: websocket.connection) { + public async listen(subscriber: EventEmitter, wsConnection: WebSocket.WebSocket) { + this.subscriber = subscriber; + this.wsConnection = wsConnection; this.wsConnection.on('message', this.onWsConnectionMessage); @@ -88,14 +90,11 @@ export default class Connection { * クライアントからメッセージ受信時 */ @bindThis - private async onWsConnectionMessage(data: websocket.Message) { - if (data.type !== 'utf8') return; - if (data.utf8Data == null) return; - + private async onWsConnectionMessage(data: WebSocket.RawData) { let obj: Record<string, any>; try { - obj = JSON.parse(data.utf8Data); + obj = JSON.parse(data.toString()); } catch (e) { return; } @@ -246,7 +245,7 @@ export default class Connection { const ch: Channel = channelService.create(id, this); this.channels.push(ch); - ch.init(params); + ch.init(params ?? {}); if (pong) { this.sendMessageToWs('connected', { diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index fd7f54da54..38ae8ad2e5 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -116,9 +116,9 @@ } } } - const colorSchema = localStorage.getItem('colorSchema'); - if (colorSchema) { - document.documentElement.style.setProperty('color-schema', colorSchema); + const colorScheme = localStorage.getItem('colorScheme'); + if (colorScheme) { + document.documentElement.style.setProperty('color-scheme', colorScheme); } //#endregion @@ -160,37 +160,41 @@ <path d="M12 9v2m0 4v.01"></path> <path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path> </svg> - <h1>An error has occurred!</h1> - <button class="button-big" onclick="location.reload();"> - <span class="button-label-big">Refresh</span> + <h1>Failed to load<br>読み込みに失敗しました</h1> + <button class="button-big" onclick="location.reload(true);"> + <span class="button-label-big">Reload / リロード</span> </button> - <p class="dont-worry">Don't worry, it's (probably) not your fault.</p> - <p>If the problem persists after refreshing, please contact your instance's administrator.<br>You may also try the following options:</p> - <p>Update your os and browser.</p> - <p>Disable an adblocker.</p> - <a href="/flush"> - <button class="button-small"> - <span class="button-label-small">Clear preferences and cache</span> - </button> - </a> - <br> - <a href="/cli"> - <button class="button-small"> - <span class="button-label-small">Start the simple client</span> - </button> - </a> - <br> - <a href="/bios"> - <button class="button-small"> - <span class="button-label-small">Start the repair tool</span> - </button> - </a> + <p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります。</b></p> + <p>Clear the browser cache / ブラウザのキャッシュをクリアする</p> + <p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p> + <p>Disable an adblocker / アドブロッカーを無効にする</p> + <details style="color: #86b300;"> + <summary>Other options / その他のオプション</summary> + <a href="/flush"> + <button class="button-small"> + <span class="button-label-small">Clear preferences and cache</span> + </button> + </a> + <br> + <a href="/cli"> + <button class="button-small"> + <span class="button-label-small">Start the simple client</span> + </button> + </a> + <br> + <a href="/bios"> + <button class="button-small"> + <span class="button-label-small">Start the repair tool</span> + </button> + </a> + </details> <br> <div id="errors"></div> `; errorsElement = document.getElementById('errors'); } const detailsElement = document.createElement('details'); + detailsElement.id = 'errorInfo'; detailsElement.innerHTML = ` <br> <summary> @@ -247,7 +251,7 @@ .button-label-big { color: #222; font-weight: bold; - font-size: 20px; + font-size: 1.2em; padding: 12px; } @@ -267,11 +271,6 @@ font-size: 16px; } - .dont-worry, - #msg { - font-size: 18px; - } - .icon-warning { color: #dec340; height: 4rem; @@ -279,14 +278,15 @@ } h1 { - font-size: 32px; + font-size: 1.5em; + margin: 1em; } code { font-family: Fira, FiraCode, monospace; } - details { + #errorInfo { background: #333; margin-bottom: 2rem; padding: 0.5rem 1rem; @@ -296,16 +296,16 @@ margin: auto; } - summary { + #errorInfo summary { cursor: pointer; } - summary > * { + #errorInfo summary > * { display: inline; } @media screen and (max-width: 500px) { - details { + #errorInfo { width: 50%; } `) diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index cb5d05a403..69b3f68e05 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -25,7 +25,6 @@ html meta(name='referrer' content='origin') meta(name='theme-color' content= themeColor || '#86b300') meta(name='theme-color-orig' content= themeColor || '#86b300') - meta(property='twitter:card' content='summary') meta(property='og:site_name' content= instanceName || 'Misskey') meta(name='viewport' content='width=device-width, initial-scale=1') link(rel='icon' href= icon || '/favicon.ico') @@ -36,7 +35,7 @@ html link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.17.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.21.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists @@ -59,6 +58,7 @@ html meta(property='og:title' content= title || 'Misskey') meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') meta(property='og:image' content= img) + meta(property='twitter:card' content='summary') style include ../style.css diff --git a/packages/backend/src/server/web/views/channel.pug b/packages/backend/src/server/web/views/channel.pug index 486f0ecc47..c514025e0b 100644 --- a/packages/backend/src/server/web/views/channel.pug +++ b/packages/backend/src/server/web/views/channel.pug @@ -16,3 +16,4 @@ block og meta(property='og:description' content= channel.description) meta(property='og:url' content= url) meta(property='og:image' content= channel.bannerUrl) + meta(property='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug index 74dc62f1e7..5a0018803a 100644 --- a/packages/backend/src/server/web/views/clip.pug +++ b/packages/backend/src/server/web/views/clip.pug @@ -17,6 +17,7 @@ block og meta(property='og:description' content= clip.description) meta(property='og:url' content= url) meta(property='og:image' content= avatarUrl) + meta(property='twitter:card' content='summary') block meta if profile.noCrawle diff --git a/packages/backend/src/server/web/views/flash.pug b/packages/backend/src/server/web/views/flash.pug index 5594fcdfbf..1549aa7906 100644 --- a/packages/backend/src/server/web/views/flash.pug +++ b/packages/backend/src/server/web/views/flash.pug @@ -17,6 +17,7 @@ block og meta(property='og:description' content= flash.summary) meta(property='og:url' content= url) meta(property='og:image' content= avatarUrl) + meta(property='twitter:card' content='summary') block meta if profile.noCrawle diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug index 10f2d269bc..a458d7f8c7 100644 --- a/packages/backend/src/server/web/views/gallery-post.pug +++ b/packages/backend/src/server/web/views/gallery-post.pug @@ -17,6 +17,7 @@ block og meta(property='og:description' content= post.description) meta(property='og:url' content= url) meta(property='og:image' content= post.files[0].thumbnailUrl) + meta(property='twitter:card' content='summary_large_image') block meta if user.host || profile.noCrawle diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index badfcccd61..874c48c602 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -5,6 +5,8 @@ block vars - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; - const url = `${config.url}/notes/${note.id}`; - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; + - const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.type.isSensitive) + - const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.type.isSensitive) block title = `${title} | ${instanceName}` @@ -17,7 +19,19 @@ block og meta(property='og:title' content= title) meta(property='og:description' content= summary) meta(property='og:url' content= url) - meta(property='og:image' content= avatarUrl) + if video + meta(property='og:video:url' content= video.url) + meta(property='og:video:secure_url' content= video.url) + meta(property='og:video:type' content= video.type) + // FIXME: add width and height + // FIXME: add embed player for Twitter + if image + meta(property='twitter:card' content='summary_large_image') + meta(property='og:image' content= image.url) + else + meta(property='twitter:card' content='summary') + meta(property='og:image' content= avatarUrl) + block meta if user.host || isRenote || profile.noCrawle diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug index ddffc361c8..08bb08ffe7 100644 --- a/packages/backend/src/server/web/views/page.pug +++ b/packages/backend/src/server/web/views/page.pug @@ -17,6 +17,7 @@ block og meta(property='og:description' content= page.summary) meta(property='og:url' content= url) meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl) + meta(property='twitter:card' content= page.eyeCatchingImage ? 'summary_large_image' : 'summary') block meta if profile.noCrawle diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug index f4c83aa89d..83d57349a6 100644 --- a/packages/backend/src/server/web/views/user.pug +++ b/packages/backend/src/server/web/views/user.pug @@ -16,6 +16,7 @@ block og meta(property='og:description' content= profile.description) meta(property='og:url' content= url) meta(property='og:image' content= avatarUrl) + meta(property='twitter:card' content='summary') block meta if user.host || profile.noCrawle diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 0addb430c9..5da997f28b 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as crypto from 'node:crypto'; -import * as cbor from 'cbor'; +import cbor from 'cbor'; import * as OTPAuth from 'otpauth'; import { loadConfig } from '../../src/config.js'; import { signup, api, post, react, startServer, waitFire } from '../utils.js'; diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts new file mode 100644 index 0000000000..dd3b09f85a --- /dev/null +++ b/packages/backend/test/e2e/antennas.ts @@ -0,0 +1,653 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { inspect } from 'node:util'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { + signup, + post, + userList, + page, + role, + startServer, + api, + successfulApiCall, + failedApiCall, + uploadFile, + testPaginationConsistency, +} from '../utils.js'; +import type * as misskey from 'misskey-js'; +import type { INestApplicationContext } from '@nestjs/common'; + +const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { + return selector(a).localeCompare(selector(b)); +}; + +describe('アンテナ', () => { + // エンティティとしてのアンテナを主眼においたテストを記述する + // (Antennaを返すエンドポイント、Antennaエンティティを書き換えるエンドポイント、Antennaからノートを取得するエンドポイントをテストする) + + // BUG misskey-jsとjson-schemaが一致していない。 + // - srcのenumにgroupが残っている + // - userGroupIdが残っている, isActiveがない + type Antenna = misskey.entities.Antenna | Packed<'Antenna'>; + type User = misskey.entities.MeDetailed & { token: string }; + type Note = misskey.entities.Note; + + // アンテナを作成できる最小のパラメタ + const defaultParam = { + caseSensitive: false, + excludeKeywords: [['']], + keywords: [['keyword']], + name: 'test', + notify: false, + src: 'all' as const, + userListId: null, + users: [''], + withFile: false, + withReplies: false, + }; + + let app: INestApplicationContext; + + let root: User; + let alice: User; + let bob: User; + let carol: User; + + let alicePost: Note; + let aliceList: misskey.entities.UserList; + let bobFile: misskey.entities.DriveFile; + let bobList: misskey.entities.UserList; + + let userNotExplorable: User; + let userLocking: User; + let userSilenced: User; + let userSuspended: User; + let userDeletedBySelf: User; + let userDeletedByAdmin: User; + let userFollowingAlice: User; + let userFollowedByAlice: User; + let userBlockingAlice: User; + let userBlockedByAlice: User; + let userMutingAlice: User; + let userMutedByAlice: User; + + beforeAll(async () => { + app = await startServer(); + }, 1000 * 60 * 2); + + beforeAll(async () => { + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); + alicePost = await post(alice, { text: 'test' }); + aliceList = await userList(alice, {}); + bob = await signup({ username: 'bob' }); + aliceList = await userList(alice, {}); + bobFile = (await uploadFile(bob)).body; + bobList = await userList(bob); + carol = await signup({ username: 'carol' }); + await api('users/lists/push', { listId: aliceList.id, userId: bob.id }, alice); + await api('users/lists/push', { listId: aliceList.id, userId: carol.id }, alice); + + userNotExplorable = await signup({ username: 'userNotExplorable' }); + await post(userNotExplorable, { text: 'test' }); + await api('i/update', { isExplorable: false }, userNotExplorable); + userLocking = await signup({ username: 'userLocking' }); + await post(userLocking, { text: 'test' }); + await api('i/update', { isLocked: true }, userLocking); + userSilenced = await signup({ username: 'userSilenced' }); + await post(userSilenced, { text: 'test' }); + const roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); + await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); + userSuspended = await signup({ username: 'userSuspended' }); + await post(userSuspended, { text: 'test' }); + await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); + await api('admin/suspend-user', { userId: userSuspended.id }, root); + userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); + await post(userDeletedBySelf, { text: 'test' }); + await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); + userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); + await post(userDeletedByAdmin, { text: 'test' }); + await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); + userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); + await post(userFollowedByAlice, { text: 'test' }); + await api('following/create', { userId: userFollowedByAlice.id }, alice); + userFollowingAlice = await signup({ username: 'userFollowingAlice' }); + await post(userFollowingAlice, { text: 'test' }); + await api('following/create', { userId: alice.id }, userFollowingAlice); + userBlockingAlice = await signup({ username: 'userBlockingAlice' }); + await post(userBlockingAlice, { text: 'test' }); + await api('blocking/create', { userId: alice.id }, userBlockingAlice); + userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); + await post(userBlockedByAlice, { text: 'test' }); + await api('blocking/create', { userId: userBlockedByAlice.id }, alice); + userMutingAlice = await signup({ username: 'userMutingAlice' }); + await post(userMutingAlice, { text: 'test' }); + await api('mute/create', { userId: alice.id }, userMutingAlice); + userMutedByAlice = await signup({ username: 'userMutedByAlice' }); + await post(userMutedByAlice, { text: 'test' }); + await api('mute/create', { userId: userMutedByAlice.id }, alice); + }, 1000 * 60 * 10); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // テスト間で影響し合わないように毎回全部消す。 + for (const user of [alice, bob]) { + const list = await api('/antennas/list', {}, user); + for (const antenna of list.body) { + await api('/antennas/delete', { antennaId: antenna.id }, user); + } + } + }); + + //#region 作成(antennas/create) + + test('が作成できること、キーが過不足なく入っていること。', async () => { + const response = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + assert.match(response.id, /[0-9a-z]{10}/); + const expected = { + id: response.id, + caseSensitive: false, + createdAt: new Date(response.createdAt).toISOString(), + excludeKeywords: [['']], + hasUnreadNote: false, + isActive: true, + keywords: [['keyword']], + name: 'test', + notify: false, + src: 'all', + userListId: null, + users: [''], + withFile: false, + withReplies: false, + } as Antenna; + assert.deepStrictEqual(response, expected); + }); + + test('が上限いっぱいまで作成できること', async () => { + // antennaLimit + 1まで作れるのがキモ + const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }))); + + const expected = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice }); + assert.deepStrictEqual( + response.sort(compareBy(s => s.id)), + expected.sort(compareBy(s => s.id))); + + failedApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }, { + status: 400, + code: 'TOO_MANY_ANTENNAS', + id: 'faf47050-e8b5-438c-913c-db2b1576fde4', + }); + }); + + test('を作成するとき他人のリストを指定したらエラーになる', async () => { + failedApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, src: 'list', userListId: bobList.id }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_USER_LIST', + id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f', + }); + }); + + const antennaParamPattern = [ + { parameters: (): object => ({ name: 'x'.repeat(100) }) }, + { parameters: (): object => ({ name: 'x' }) }, + { parameters: (): object => ({ src: 'home' }) }, + { parameters: (): object => ({ src: 'all' }) }, + { parameters: (): object => ({ src: 'users' }) }, + { parameters: (): object => ({ src: 'list' }) }, + { parameters: (): object => ({ userListId: null }) }, + { parameters: (): object => ({ src: 'list', userListId: aliceList.id }) }, + { parameters: (): object => ({ keywords: [['x']] }) }, + { parameters: (): object => ({ keywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) }, + { parameters: (): object => ({ excludeKeywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) }, + { parameters: (): object => ({ users: [alice.username] }) }, + { parameters: (): object => ({ users: [alice.username, bob.username, carol.username] }) }, + { parameters: (): object => ({ caseSensitive: false }) }, + { parameters: (): object => ({ caseSensitive: true }) }, + { parameters: (): object => ({ withReplies: false }) }, + { parameters: (): object => ({ withReplies: true }) }, + { parameters: (): object => ({ withFile: false }) }, + { parameters: (): object => ({ withFile: true }) }, + { parameters: (): object => ({ notify: false }) }, + { parameters: (): object => ({ notify: true }) }, + ]; + test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { + const response = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, ...parameters() }, + user: alice, + }); + const expected = { ...response, ...parameters() }; + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region 更新(antennas/update) + + test.each(antennaParamPattern)('を変更できること($#)', async ({ parameters }) => { + const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); + const response = await successfulApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, ...parameters() }, + user: alice, + }); + const expected = { ...response, ...parameters() }; + assert.deepStrictEqual(response, expected); + }); + test.todo('は他人のものは変更できない'); + + test('を変更するとき他人のリストを指定したらエラーになる', async () => { + const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); + failedApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: bobList.id }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_USER_LIST', + id: '1c6b35c9-943e-48c2-81e4-2844989407f7', + }); + }); + + //#endregion + //#region 表示(antennas/show) + + test('をID指定で表示できること。', async () => { + const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); + const response = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: alice, + }); + const expected = { ...antenna }; + assert.deepStrictEqual(response, expected); + }); + test.todo('は他人のものをID指定で表示できない'); + + //#endregion + //#region 一覧(antennas/list) + + test('をリスト形式で取得できること。', async () => { + const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); + await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: bob }); + const response = await successfulApiCall({ + endpoint: 'antennas/list', + parameters: {}, + user: alice, + }); + const expected = [{ ...antenna }]; + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region 削除(antennas/delete) + + test('を削除できること。', async () => { + const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); + const response = await successfulApiCall({ + endpoint: 'antennas/delete', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.deepStrictEqual(response, null); + const list = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice }); + assert.deepStrictEqual(list, []); + }); + test.todo('は他人のものを削除できない'); + + //#endregion + + describe('のノート', () => { + //#region アンテナのノート取得(antennas/notes) + + test('を取得できること。', async () => { + const keyword = 'キーワード'; + await post(bob, { text: `test ${keyword} beforehand` }); + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]] }, + user: alice, + }); + const note = await post(bob, { text: `test ${keyword}` }); + const response = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + const expected = [note]; + assert.deepStrictEqual(response, expected); + }); + + const keyword = 'キーワード'; + test.each([ + { + label: '全体から', + parameters: (): object => ({ src: 'all' }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + // BUG e4144a1 以降home指定は壊れている(allと同じ) + label: 'ホーム指定はallと同じ', + parameters: (): object => ({ src: 'home' }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + // https://github.com/misskey-dev/misskey/issues/9025 + label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, + ], + }, + { + label: 'ブロックしているユーザーのノートは含む', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userBlockedByAlice, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'ブロックされているユーザーのノートは含まない', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userBlockingAlice, { text: `${keyword}` }) }, + ], + }, + { + label: 'ミュートしているユーザーのノートは含まない', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userMutedByAlice, { text: `${keyword}` }) }, + ], + }, + { + label: 'ミュートされているユーザーのノートは含む', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userMutingAlice, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '「見つけやすくする」がOFFのユーザーのノートも含まれる', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userNotExplorable, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '鍵付きユーザーのノートも含まれる', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userLocking, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'サイレンスのノートも含まれる', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userSilenced, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '削除ユーザーのノートも含まれる', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userDeletedBySelf, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(userDeletedByAdmin, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'ユーザー指定で', + parameters: (): object => ({ src: 'users', users: [`@${bob.username}`, `@${carol.username}`] }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + label: 'リスト指定で', + parameters: (): object => ({ src: 'list', userListId: aliceList.id }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + label: 'CWにもマッチする', + parameters: (): object => ({ keywords: [[keyword]] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'test', cw: `cw ${keyword}` }), included: true }, + ], + }, + { + label: 'キーワード1つ', + parameters: (): object => ({ keywords: [[keyword]] }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: 'test' }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: 'test' }) }, + ], + }, + { + label: 'キーワード3つ(AND)', + parameters: (): object => ({ keywords: [['A', 'B', 'C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'test A' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test A B' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test B C' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test A B C' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test C B A A B C' }), included: true }, + ], + }, + { + label: 'キーワード3つ(OR)', + parameters: (): object => ({ keywords: [['A'], ['B'], ['C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'test' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test A' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test A B' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test B C' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test B C A' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test C B' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test C' }), included: true }, + ], + }, + { + label: '除外ワード3つ(AND)', + parameters: (): object => ({ excludeKeywords: [['A', 'B', 'C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }), included: true }, + ], + }, + { + label: '除外ワード3つ(OR)', + parameters: (): object => ({ excludeKeywords: [['A'], ['B'], ['C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }) }, + ], + }, + { + label: 'キーワード1つ(大文字小文字区別する)', + parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'keyword' }) }, + { note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }) }, + { note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true }, + ], + }, + { + label: 'キーワード1つ(大文字小文字区別しない)', + parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'keyword' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true }, + ], + }, + { + label: '除外ワード1つ(大文字小文字区別する)', + parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) }, + ], + }, + { + label: '除外ワード1つ(大文字小文字区別しない)', + parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }) }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }) }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) }, + ], + }, + { + label: '添付ファイルを問わない', + parameters: (): object => ({ withFile: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '添付ファイル付きのみ', + parameters: (): object => ({ withFile: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }) }, + ], + }, + { + label: 'リプライ以外', + parameters: (): object => ({ withReplies: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }) }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'リプライも含む', + parameters: (): object => ({ withReplies: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + ], + }, + ])('が取得できること($label)', async ({ parameters, posts }) => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]], ...parameters() }, + user: alice, + }); + + const notes = await posts.reduce(async (prev, current) => { + // includedに関わらずnote()は評価して投稿する。 + const p = await prev; + const n = await current.note(); + if (current.included) return p.concat(n); + return p; + }, Promise.resolve([] as Note[])); + + // alice視点でNoteを取り直す + const expected = await Promise.all(notes.reverse().map(s => successfulApiCall({ + endpoint: 'notes/show', + parameters: { noteId: s.id }, + user: alice, + }))); + + const response = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.deepStrictEqual( + response.map(({ userId, id, text }) => ({ userId, id, text })), + expected.map(({ userId, id, text }) => ({ userId, id, text }))); + assert.deepStrictEqual(response, expected); + }); + + test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { }); + test.each([ + { label: 'ID指定', offsetBy: 'id' }, + + // BUG sinceDate, untilDateはsinceIdや他のエンドポイントとは異なり、その時刻に一致するレコードを含んでしまう。 + // { label: '日付指定', offsetBy: 'createdAt' }, + ] as const)('が取得でき、$labelのPaginationに一貫性があること', async ({ offsetBy }) => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]] }, + user: alice, + }); + const notes = await [...Array(30)].reduce(async (prev, current, index) => { + const p = await prev; + const n = await post(alice, { text: `${keyword} (${index})` }); + return [n].concat(p); + }, Promise.resolve([] as Note[])); + + // antennas/notesは降順のみで、昇順をサポートしない。 + await testPaginationConsistency(notes, async (paginationParam) => { + return successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id, ...paginationParam }, + user: alice, + }) as any as Note[]; + }, offsetBy, 'desc'); + }); + + // BUG 7日過ぎると作り直すしかない。 https://github.com/misskey-dev/misskey/issues/10476 + test.todo('を取得したときActiveに戻る'); + + //#endregion + }); +}); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index a7f8210c8e..02684c93b8 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -43,7 +43,6 @@ describe('ユーザー', () => { type MeDetailed = UserDetailedNotMe & misskey.entities.MeDetailed & { - showTimelineReplies: boolean, achievements: object[], loggedInDays: number, policies: object, @@ -160,7 +159,6 @@ describe('ユーザー', () => { mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, emailNotificationTypes: user.emailNotificationTypes, - showTimelineReplies: user.showTimelineReplies, achievements: user.achievements, loggedInDays: user.loggedInDays, policies: user.policies, @@ -406,7 +404,6 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutingNotificationTypes, []); assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); - assert.strictEqual(response.showTimelineReplies, false); assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); @@ -470,8 +467,6 @@ describe('ユーザー', () => { { parameters: (): object => ({ isBot: false }) }, { parameters: (): object => ({ isCat: true }) }, { parameters: (): object => ({ isCat: false }) }, - { parameters: (): object => ({ showTimelineReplies: true }) }, - { parameters: (): object => ({ showTimelineReplies: false }) }, { parameters: (): object => ({ injectFeaturedNote: true }) }, { parameters: (): object => ({ injectFeaturedNote: false }) }, { parameters: (): object => ({ receiveAnnouncementEmail: true }) }, diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 6b31e68616..a7bcd859ae 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -52,11 +52,7 @@ export class MockResolver extends Resolver { const r = this._rs.get(value); if (!r) { - throw { - name: 'StatusError', - statusCode: 404, - message: 'Not registed for mock', - }; + throw new Error('Not registed for mock'); } const object = JSON.parse(r.content); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts index 38db081ac0..aa68f4117d 100644 --- a/packages/backend/test/unit/ReactionService.ts +++ b/packages/backend/test/unit/ReactionService.ts @@ -15,78 +15,74 @@ describe('ReactionService', () => { reactionService = app.get<ReactionService>(ReactionService); }); - describe('toDbReaction', () => { + describe('normalize', () => { test('絵文字リアクションはそのまま', async () => { - assert.strictEqual(await reactionService.toDbReaction('👍'), '👍'); - assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅'); + assert.strictEqual(await reactionService.normalize('👍'), '👍'); + assert.strictEqual(await reactionService.normalize('🍅'), '🍅'); }); test('既存のリアクションは絵文字化する pudding', async () => { - assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮'); + assert.strictEqual(await reactionService.normalize('pudding'), '🍮'); }); test('既存のリアクションは絵文字化する like', async () => { - assert.strictEqual(await reactionService.toDbReaction('like'), '👍'); + assert.strictEqual(await reactionService.normalize('like'), '👍'); }); test('既存のリアクションは絵文字化する love', async () => { - assert.strictEqual(await reactionService.toDbReaction('love'), '❤'); + assert.strictEqual(await reactionService.normalize('love'), '❤'); }); test('既存のリアクションは絵文字化する laugh', async () => { - assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆'); + assert.strictEqual(await reactionService.normalize('laugh'), '😆'); }); test('既存のリアクションは絵文字化する hmm', async () => { - assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔'); + assert.strictEqual(await reactionService.normalize('hmm'), '🤔'); }); test('既存のリアクションは絵文字化する surprise', async () => { - assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮'); + assert.strictEqual(await reactionService.normalize('surprise'), '😮'); }); test('既存のリアクションは絵文字化する congrats', async () => { - assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉'); + assert.strictEqual(await reactionService.normalize('congrats'), '🎉'); }); test('既存のリアクションは絵文字化する angry', async () => { - assert.strictEqual(await reactionService.toDbReaction('angry'), '💢'); + assert.strictEqual(await reactionService.normalize('angry'), '💢'); }); test('既存のリアクションは絵文字化する confused', async () => { - assert.strictEqual(await reactionService.toDbReaction('confused'), '😥'); + assert.strictEqual(await reactionService.normalize('confused'), '😥'); }); test('既存のリアクションは絵文字化する rip', async () => { - assert.strictEqual(await reactionService.toDbReaction('rip'), '😇'); + assert.strictEqual(await reactionService.normalize('rip'), '😇'); }); test('既存のリアクションは絵文字化する star', async () => { - assert.strictEqual(await reactionService.toDbReaction('star'), '⭐'); + assert.strictEqual(await reactionService.normalize('star'), '⭐'); }); test('異体字セレクタ除去', async () => { - assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗'); + assert.strictEqual(await reactionService.normalize('㊗️'), '㊗'); }); test('異体字セレクタ除去 必要なし', async () => { - assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗'); - }); - - test('fallback - undefined', async () => { - assert.strictEqual(await reactionService.toDbReaction(undefined), '❤'); + assert.strictEqual(await reactionService.normalize('㊗'), '㊗'); }); test('fallback - null', async () => { - assert.strictEqual(await reactionService.toDbReaction(null), '❤'); + assert.strictEqual(await reactionService.normalize(null), '❤'); }); test('fallback - empty', async () => { - assert.strictEqual(await reactionService.toDbReaction(''), '❤'); + assert.strictEqual(await reactionService.normalize(''), '❤'); }); test('fallback - unknown', async () => { - assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); + assert.strictEqual(await reactionService.normalize('unknown'), '❤'); }); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 809ed2c66c..22f7d81e4e 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -124,6 +124,13 @@ export const react = async (user: any, note: any, reaction: string): Promise<any }, user); }; +export const userList = async (user: any, userList: any = {}): Promise<any> => { + const res = await api('users/lists/create', { + name: 'test', + }, user); + return res.body; +}; + export const page = async (user: any, page: any = {}): Promise<any> => { const res = await api('pages/create', { alignCenter: false, @@ -380,8 +387,98 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde }; }; +/** + * あるAPIエンドポイントのPaginationが複数の条件で一貫した挙動であることをテストします。 + * (sinceId, untilId, sinceDate, untilDate, offset, limit) + * @param expected 期待値となるEntityの並び(例:Note[])昇順降順が一致している必要がある + * @param fetchEntities Entity[]を返却するテスト対象のAPIを呼び出す関数 + * @param offsetBy 何をキーとしてPaginationするか。 + * @param ordering 昇順・降順 + */ +export async function testPaginationConsistency<Entity extends { id: string, createdAt?: string }>( + expected: Entity[], + fetchEntities: (paginationParam: { + limit?: number, + offset?: number, + sinceId?: string, + untilId?: string, + sinceDate?: number, + untilDate?: number, + }) => Promise<Entity[]>, + offsetBy: 'offset' | 'id' | 'createdAt' = 'id', + ordering: 'desc' | 'asc' = 'desc'): Promise<void> { + const rangeToParam = (p: { limit?: number, until?: Entity, since?: Entity }): object => { + if (offsetBy === 'id') { + return { limit: p.limit, sinceId: p.since?.id, untilId: p.until?.id }; + } else { + const sinceDate = p.since?.createdAt !== undefined ? new Date(p.since.createdAt).getTime() : undefined; + const untilDate = p.until?.createdAt !== undefined ? new Date(p.until.createdAt).getTime() : undefined; + return { limit: p.limit, sinceDate, untilDate }; + } + }; + + for (const limit of [1, 5, 10, 100, undefined]) { + // 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること + if (ordering === 'desc') { + const end = expected[expected.length - 1]; + let last = await fetchEntities(rangeToParam({ limit, since: end })); + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1], since: end })); + } + actual.push(end); + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + + // 2. sinceId/Date指定+limitで取得してつなぎ合わせた結果が期待通りになっていること + if (ordering === 'asc') { + // 昇順にしたときの先頭(一番古いもの)をもってくる(expected[1]を基準に降順にして0番目) + let last = await fetchEntities({ limit: 1, untilId: expected[1].id }); + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities(rangeToParam({ limit, since: last[last.length - 1] })); + } + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + + // 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること + if (ordering === 'desc') { + let last = await fetchEntities({ limit }); + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1] })); + } + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + + // 4. offset指定+limitで取得してつなぎ合わせた結果が期待通りになっていること + if (offsetBy === 'offset') { + let last = await fetchEntities({ limit, offset: 0 }); + let offset = limit ?? 10; + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities({ limit, offset }); + offset += limit ?? 10; + } + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + } +} + export async function initTestDb(justBorrow = false, initEntities?: any[]) { - if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); const db = new DataSource({ type: 'postgres', diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js index e8e0e57d2a..24c3ad4b83 100644 --- a/packages/frontend/.eslintrc.js +++ b/packages/frontend/.eslintrc.js @@ -56,14 +56,15 @@ module.exports = { 'vue/require-v-for-key': 'warn', 'vue/no-unused-components': 'warn', 'vue/no-unused-vars': 'warn', + 'vue/no-dupe-keys': 'warn', 'vue/valid-v-for': 'warn', 'vue/return-in-computed-property': 'warn', 'vue/no-setup-props-destructure': 'warn', 'vue/max-attributes-per-line': 'off', 'vue/html-self-closing': 'off', 'vue/singleline-html-element-content-newline': 'off', - // (vue/vue3-recommended disabled the autofix for Vue 2 compatibility) - 'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }], + 'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }], + 'vue/attribute-hyphenation': ['error', 'never'], }, globals: { // Node.js diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 7c51d4c00c..f442422109 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -397,6 +397,7 @@ function toStories(component: string): string { Promise.all([ glob('src/components/global/*.vue'), glob('src/components/Mk{A,B}*.vue'), + glob('src/components/MkDigitalClock.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index ab694f64fb..f6a9a4875d 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -1,6 +1,6 @@ <link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true" as="image" type="image/png" crossorigin="anonymous"> <link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true" as="image" type="image/jpeg" crossorigin="anonymous"> -<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css"> +<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.21.0/tabler-icons.min.css"> <link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css"> <style> html { diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts new file mode 100644 index 0000000000..3929bf0608 --- /dev/null +++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts @@ -0,0 +1,597 @@ +import { parse } from 'acorn'; +import { generate } from 'astring'; +import { describe, expect, it } from 'vitest'; +import { normalizeClass, unwindCssModuleClassName } from './rollup-plugin-unwind-css-module-class-name'; +import type * as estree from 'estree'; + +function parseExpression(code: string): estree.Expression { + const program = parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as estree.Program; + const statement = program.body[0] as estree.ExpressionStatement; + return statement.expression; +} + +describe(normalizeClass.name, () => { + it('should normalize string', () => { + expect(normalizeClass(parseExpression('"a b c"'))).toBe('a b c'); + }); + it('should trim redundant spaces', () => { + expect(normalizeClass(parseExpression('" a b c "'))).toBe('a b c'); + }); + it('should ignore undefined', () => { + expect(normalizeClass(parseExpression('undefined'))).toBe(''); + }); + it('should ignore non string literals', () => { + expect(normalizeClass(parseExpression('0'))).toBe(''); + expect(normalizeClass(parseExpression('true'))).toBe(''); + expect(normalizeClass(parseExpression('null'))).toBe(''); + expect(normalizeClass(parseExpression('/I.D/'))).toBe(''); + }); + it('should not normalize identifiers', () => { + expect(normalizeClass(parseExpression('EScape'))).toBeNull(); + }); + it('should normalize recursively array', () => { + expect(normalizeClass(parseExpression('["from", ...["Utopia"]]'))).toBe('from Utopia'); + expect(normalizeClass(parseExpression('["from", ...[Utopia]]'))).toBeNull(); + }); + it('should normalize recursively template literal', () => { + expect(normalizeClass(parseExpression('`name ${"shiho"} code ${33}`'))).toBe('name shiho code'); + expect(normalizeClass(parseExpression('`name ${shiho.name} code ${33}`'))).toBeNull(); + }); + it('should normalize recursively binary expression', () => { + expect(normalizeClass(parseExpression('"mirage" + "mirror"'))).toBe('miragemirror'); + expect(normalizeClass(parseExpression('"mirage" + mirror'))).toBeNull(); + }); + it('should normalize recursively object expression', () => { + expect(normalizeClass(parseExpression('({ a: true, b: "c" })'))).toBe('a b'); + expect(normalizeClass(parseExpression('({ a: false, b: "c" })'))).toBe('b'); + expect(normalizeClass(parseExpression('({ a: true, b: c })'))).toBeNull(); + expect(normalizeClass(parseExpression('({ a: true, b: "c", ...({ d: true }) })'))).toBe('a b d'); + expect(normalizeClass(parseExpression('({ a: true, [b]: "c" })'))).toBeNull(); + expect(normalizeClass(parseExpression('({ a: true, b: false, c: !false, d: !!0 })'))).toBe('a c'); + }); +}); + +it('Composition API (standard)', () => { + const ast = parse(` +import { c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js'; +import { M as MkContainer } from './MkContainer-!~{03M}~.js'; +import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js'; +import './photoswipe-!~{003}~.js'; + +const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1); +const _sfc_main = /* @__PURE__ */ defineComponent({ + __name: "index.photos", + props: { + user: {} + }, + setup(__props) { + const props = __props; + let fetching = ref(true); + let images = ref([]); + function thumbnail(image) { + return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl; + } + onMounted(() => { + const image = [ + "image/jpeg", + "image/webp", + "image/avif", + "image/png", + "image/gif", + "image/apng", + "image/vnd.mozilla.apng" + ]; + api("users/notes", { + userId: props.user.id, + fileType: image, + excludeNsfw: defaultStore.state.nsfw !== "ignore", + limit: 10 + }).then((notes) => { + for (const note of notes) { + for (const file of note.files) { + images.value.push({ + note, + file + }); + } + } + fetching.value = false; + }); + }); + return (_ctx, _cache) => { + const _component_MkLoading = resolveComponent("MkLoading"); + const _component_MkA = resolveComponent("MkA"); + return openBlock(), createBlock(MkContainer, { + "max-height": 300, + foldable: true + }, { + icon: withCtx(() => [ + _hoisted_1 + ]), + header: withCtx(() => [ + createTextVNode(toDisplayString(unref(i18n).ts.images), 1) + ]), + default: withCtx(() => [ + createBaseVNode("div", { + class: normalizeClass(_ctx.$style.root) + }, [ + unref(fetching) ? (openBlock(), createBlock(_component_MkLoading, { key: 0 })) : createCommentVNode("", true), + !unref(fetching) && unref(images).length > 0 ? (openBlock(), createElementBlock("div", { + key: 1, + class: normalizeClass(_ctx.$style.stream) + }, [ + (openBlock(true), createElementBlock(Fragment, null, renderList(unref(images), (image) => { + return openBlock(), createBlock(_component_MkA, { + key: image.note.id + image.file.id, + class: normalizeClass(_ctx.$style.img), + to: unref(notePage)(image.note) + }, { + default: withCtx(() => [ + createVNode(ImgWithBlurhash, { + hash: image.file.blurhash, + src: thumbnail(image.file), + title: image.file.name + }, null, 8, ["hash", "src", "title"]) + ]), + _: 2 + }, 1032, ["class", "to"]); + }), 128)) + ], 2)) : createCommentVNode("", true), + !unref(fetching) && unref(images).length == 0 ? (openBlock(), createElementBlock("p", { + key: 2, + class: normalizeClass(_ctx.$style.empty) + }, toDisplayString(unref(i18n).ts.nothing), 3)) : createCommentVNode("", true) + ], 2) + ]), + _: 1 + }); + }; + } +}); + +const root = "xenMW"; +const stream = "xaZzf"; +const img = "xtA8t"; +const empty = "xhYKj"; +const style0 = { + root: root, + stream: stream, + img: img, + empty: empty +}; + +const cssModules = { + "$style": style0 +}; +const index_photos = /* @__PURE__ */ _export_sfc(_sfc_main, [["__cssModules", cssModules]]); + +export { index_photos as default }; +`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' }); + unwindCssModuleClassName(ast); + expect(generate(ast)).toBe(` +import {c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js'; +import {M as MkContainer} from './MkContainer-!~{03M}~.js'; +import {b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode} from './vue-!~{002}~.js'; +import './photoswipe-!~{003}~.js'; +const _hoisted_1 = createBaseVNode("i", { + class: "ti ti-photo" +}, null, -1); +const _sfc_main = defineComponent({ + __name: "index.photos", + props: { + user: {} + }, + setup(__props) { + const props = __props; + let fetching = ref(true); + let images = ref([]); + function thumbnail(image) { + return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl; + } + onMounted(() => { + const image = ["image/jpeg", "image/webp", "image/avif", "image/png", "image/gif", "image/apng", "image/vnd.mozilla.apng"]; + api("users/notes", { + userId: props.user.id, + fileType: image, + excludeNsfw: defaultStore.state.nsfw !== "ignore", + limit: 10 + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + images.value.push({ + note, + file + }); + } + } + fetching.value = false; + }); + }); + return (_ctx, _cache) => { + const _component_MkLoading = resolveComponent("MkLoading"); + const _component_MkA = resolveComponent("MkA"); + return (openBlock(), createBlock(MkContainer, { + "max-height": 300, + foldable: true + }, { + icon: withCtx(() => [_hoisted_1]), + header: withCtx(() => [createTextVNode(toDisplayString(unref(i18n).ts.images), 1)]), + default: withCtx(() => [createBaseVNode("div", { + class: "xenMW" + }, [unref(fetching) ? (openBlock(), createBlock(_component_MkLoading, { + key: 0 + })) : createCommentVNode("", true), !unref(fetching) && unref(images).length > 0 ? (openBlock(), createElementBlock("div", { + key: 1, + class: "xaZzf" + }, [(openBlock(true), createElementBlock(Fragment, null, renderList(unref(images), image => { + return (openBlock(), createBlock(_component_MkA, { + key: image.note.id + image.file.id, + class: "xtA8t", + to: unref(notePage)(image.note) + }, { + default: withCtx(() => [createVNode(ImgWithBlurhash, { + hash: image.file.blurhash, + src: thumbnail(image.file), + title: image.file.name + }, null, 8, ["hash", "src", "title"])]), + _: 2 + }, 1032, ["class", "to"])); + }), 128))], 2)) : createCommentVNode("", true), !unref(fetching) && unref(images).length == 0 ? (openBlock(), createElementBlock("p", { + key: 2, + class: "xhYKj" + }, toDisplayString(unref(i18n).ts.nothing), 3)) : createCommentVNode("", true)], 2)]), + _: 1 + })); + }; + } +}); +const root = "xenMW"; +const stream = "xaZzf"; +const img = "xtA8t"; +const empty = "xhYKj"; +const style0 = { + root: root, + stream: stream, + img: img, + empty: empty +}; +const cssModules = { + "$style": style0 +}; +const index_photos = _sfc_main; +export {index_photos as default}; +`.slice(1)); +}); + +it('Composition API (with `useCssModule()`)', () => { + const ast = parse(` +import { a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup } from './!~{002}~.js'; +import { d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js'; + +function isDebuggerEnabled(id) { + try { + return localStorage.getItem(\`DEBUG_\${id}\`) !== null; + } catch { + return false; + } +} +function stackTraceInstances() { + let instance = getCurrentInstance(); + const stack = []; + while (instance) { + stack.push(instance); + instance = instance.parent; + } + return stack; +} + +const _sfc_main = defineComponent({ + props: { + items: { + type: Array, + required: true + }, + direction: { + type: String, + required: false, + default: "down" + }, + reversed: { + type: Boolean, + required: false, + default: false + }, + noGap: { + type: Boolean, + required: false, + default: false + }, + ad: { + type: Boolean, + required: false, + default: false + } + }, + setup(props, { slots, expose }) { + const $style = useCssModule(); + function getDateText(time) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return i18n.t("monthAndDay", { + month: month.toString(), + day: date.toString() + }); + } + if (props.items.length === 0) + return; + const renderChildrenImpl = () => props.items.map((item, i) => { + if (!slots || !slots.default) + return; + const el = slots.default({ + item + })[0]; + if (el.key == null && item.id) + el.key = item.id; + if (i !== props.items.length - 1 && new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()) { + const separator = h("div", { + class: $style["separator"], + key: item.id + ":separator" + }, h("p", { + class: $style["date"] + }, [ + h("span", { + class: $style["date-1"] + }, [ + h("i", { + class: \`ti ti-chevron-up \${$style["date-1-icon"]}\` + }), + getDateText(item.createdAt) + ]), + h("span", { + class: $style["date-2"] + }, [ + getDateText(props.items[i + 1].createdAt), + h("i", { + class: \`ti ti-chevron-down \${$style["date-2-icon"]}\` + }) + ]) + ])); + return [el, separator]; + } else { + if (props.ad && item._shouldInsertAd_) { + return [h(MkAd, { + key: item.id + ":ad", + prefer: ["horizontal", "horizontal-big"] + }), el]; + } else { + return el; + } + } + }); + const renderChildren = () => { + const children = renderChildrenImpl(); + if (isDebuggerEnabled(6864)) { + const nodes = children.flatMap((node) => node ?? []); + const keys = new Set(nodes.map((node) => node.key)); + if (keys.size !== nodes.length) { + const id = crypto.randomUUID(); + const instances = stackTraceInstances(); + toast(instances.reduce((a, c) => \`\${a} at \${c.type.name}\`, \`[DEBUG_6864 (\${id})]: \${nodes.length - keys.size} duplicated keys found\`)); + console.warn({ id, debugId: 6864, stack: instances }); + } + } + return children; + }; + function onBeforeLeave(el) { + el.style.top = \`\${el.offsetTop}px\`; + el.style.left = \`\${el.offsetLeft}px\`; + } + function onLeaveCanceled(el) { + el.style.top = ""; + el.style.left = ""; + } + return () => h( + defaultStore.state.animation ? TransitionGroup : "div", + { + class: { + [$style["date-separated-list"]]: true, + [$style["date-separated-list-nogap"]]: props.noGap, + [$style["reversed"]]: props.reversed, + [$style["direction-down"]]: props.direction === "down", + [$style["direction-up"]]: props.direction === "up" + }, + ...defaultStore.state.animation ? { + name: "list", + tag: "div", + onBeforeLeave, + onLeaveCanceled + } : {} + }, + { default: renderChildren } + ); + } +}); + +const reversed = "xxiZh"; +const separator = "xxeDx"; +const date = "xxawD"; +const style0 = { + "date-separated-list": "xfKPa", + "date-separated-list-nogap": "xf9zr", + "direction-up": "x7AeO", + "direction-down": "xBIqc", + reversed: reversed, + separator: separator, + date: date, + "date-1": "xwtmh", + "date-1-icon": "xsNPa", + "date-2": "x1xvw", + "date-2-icon": "x9ZiG" +}; + +const cssModules = { + "$style": style0 +}; +const MkDateSeparatedList = /* @__PURE__ */ _export_sfc(_sfc_main, [["__cssModules", cssModules]]); + +export { MkDateSeparatedList as M }; +`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' }); + unwindCssModuleClassName(ast); + expect(generate(ast)).toBe(` +import {a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup} from './!~{002}~.js'; +import {d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js'; +function isDebuggerEnabled(id) { + try { + return localStorage.getItem(\`DEBUG_\${id}\`) !== null; + } catch { + return false; + } +} +function stackTraceInstances() { + let instance = getCurrentInstance(); + const stack = []; + while (instance) { + stack.push(instance); + instance = instance.parent; + } + return stack; +} +const _sfc_main = defineComponent({ + props: { + items: { + type: Array, + required: true + }, + direction: { + type: String, + required: false, + default: "down" + }, + reversed: { + type: Boolean, + required: false, + default: false + }, + noGap: { + type: Boolean, + required: false, + default: false + }, + ad: { + type: Boolean, + required: false, + default: false + } + }, + setup(props, {slots, expose}) { + const $style = useCssModule(); + function getDateText(time) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return i18n.t("monthAndDay", { + month: month.toString(), + day: date.toString() + }); + } + if (props.items.length === 0) return; + const renderChildrenImpl = () => props.items.map((item, i) => { + if (!slots || !slots.default) return; + const el = slots.default({ + item + })[0]; + if (el.key == null && item.id) el.key = item.id; + if (i !== props.items.length - 1 && new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()) { + const separator = h("div", { + class: $style["separator"], + key: item.id + ":separator" + }, h("p", { + class: $style["date"] + }, [h("span", { + class: $style["date-1"] + }, [h("i", { + class: \`ti ti-chevron-up \${$style["date-1-icon"]}\` + }), getDateText(item.createdAt)]), h("span", { + class: $style["date-2"] + }, [getDateText(props.items[i + 1].createdAt), h("i", { + class: \`ti ti-chevron-down \${$style["date-2-icon"]}\` + })])])); + return [el, separator]; + } else { + if (props.ad && item._shouldInsertAd_) { + return [h(MkAd, { + key: item.id + ":ad", + prefer: ["horizontal", "horizontal-big"] + }), el]; + } else { + return el; + } + } + }); + const renderChildren = () => { + const children = renderChildrenImpl(); + if (isDebuggerEnabled(6864)) { + const nodes = children.flatMap(node => node ?? []); + const keys = new Set(nodes.map(node => node.key)); + if (keys.size !== nodes.length) { + const id = crypto.randomUUID(); + const instances = stackTraceInstances(); + toast(instances.reduce((a, c) => \`\${a} at \${c.type.name}\`, \`[DEBUG_6864 (\${id})]: \${nodes.length - keys.size} duplicated keys found\`)); + console.warn({ + id, + debugId: 6864, + stack: instances + }); + } + } + return children; + }; + function onBeforeLeave(el) { + el.style.top = \`\${el.offsetTop}px\`; + el.style.left = \`\${el.offsetLeft}px\`; + } + function onLeaveCanceled(el) { + el.style.top = ""; + el.style.left = ""; + } + return () => h(defaultStore.state.animation ? TransitionGroup : "div", { + class: { + [$style["date-separated-list"]]: true, + [$style["date-separated-list-nogap"]]: props.noGap, + [$style["reversed"]]: props.reversed, + [$style["direction-down"]]: props.direction === "down", + [$style["direction-up"]]: props.direction === "up" + }, + ...defaultStore.state.animation ? { + name: "list", + tag: "div", + onBeforeLeave, + onLeaveCanceled + } : {} + }, { + default: renderChildren + }); + } +}); +const reversed = "xxiZh"; +const separator = "xxeDx"; +const date = "xxawD"; +const style0 = { + "date-separated-list": "xfKPa", + "date-separated-list-nogap": "xf9zr", + "direction-up": "x7AeO", + "direction-down": "xBIqc", + reversed: reversed, + separator: separator, + date: date, + "date-1": "xwtmh", + "date-1-icon": "xsNPa", + "date-2": "x1xvw", + "date-2-icon": "x9ZiG" +}; +const cssModules = { + "$style": style0 +}; +const MkDateSeparatedList = _export_sfc(_sfc_main, [["__cssModules", cssModules]]); +export {MkDateSeparatedList as M}; +`.slice(1)); +}); diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts new file mode 100644 index 0000000000..a18f0d9049 --- /dev/null +++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts @@ -0,0 +1,275 @@ +import { generate } from 'astring'; +import * as estree from 'estree'; +import { walk } from '../node_modules/estree-walker/src/index.js'; +import type * as estreeWalker from 'estree-walker'; +import type { Plugin } from 'vite'; + +function isFalsyIdentifier(identifier: estree.Identifier): boolean { + return identifier.name === 'undefined' || identifier.name === 'NaN'; +} + +function normalizeClassWalker(tree: estree.Node): string | null { + if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null; + if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : ''; + if (tree.type === 'BinaryExpression') { + if (tree.operator !== '+') return null; + const left = normalizeClassWalker(tree.left); + const right = normalizeClassWalker(tree.right); + if (left === null || right === null) return null; + return `${left}${right}`; + } + if (tree.type === 'TemplateLiteral') { + if (tree.expressions.some((x) => x.type !== 'Literal' && (x.type !== 'Identifier' || !isFalsyIdentifier(x)))) return null; + return tree.quasis.reduce((a, c, i) => { + const v = i === tree.quasis.length - 1 ? '' : (tree.expressions[i] as Partial<estree.Literal>).value; + return a + c.value.raw + (typeof v === 'string' ? v : ''); + }, ''); + } + if (tree.type === 'ArrayExpression') { + const values = tree.elements.map((treeNode) => { + if (treeNode === null) return ''; + if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument); + return normalizeClassWalker(treeNode); + }); + if (values.some((x) => x === null)) return null; + return values.join(' '); + } + if (tree.type === 'ObjectExpression') { + const values = tree.properties.map((treeNode) => { + if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument); + let x = treeNode.value; + let inveted = false; + while (x.type === 'UnaryExpression' && x.operator === '!') { + x = x.argument; + inveted = !inveted; + } + if (x.type === 'Literal') { + if (inveted === !x.value) { + return treeNode.key.type === 'Identifier' ? treeNode.computed ? null : treeNode.key.name : treeNode.key.type === 'Literal' ? treeNode.key.value : ''; + } else { + return ''; + } + } + if (x.type === 'Identifier') { + if (inveted !== isFalsyIdentifier(x)) { + return ''; + } else { + return null; + } + } + return null; + }); + if (values.some((x) => x === null)) return null; + return values.join(' '); + } + console.error(`Unexpected node type: ${tree.type}`); + return null; +} + +export function normalizeClass(tree: estree.Node): string | null { + const walked = normalizeClassWalker(tree); + return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, ''); +} + +export function unwindCssModuleClassName(ast: estree.Node): void { + (walk as typeof estreeWalker.walk)(ast, { + enter(node, parent): void { + if (parent?.type !== 'Program') return; + if (node.type !== 'VariableDeclaration') return; + if (node.declarations.length !== 1) return; + if (node.declarations[0].id.type !== 'Identifier') return; + const name = node.declarations[0].id.name; + if (node.declarations[0].init?.type !== 'CallExpression') return; + if (node.declarations[0].init.callee.type !== 'Identifier') return; + if (node.declarations[0].init.callee.name !== '_export_sfc') return; + if (node.declarations[0].init.arguments.length !== 2) return; + if (node.declarations[0].init.arguments[0].type !== 'Identifier') return; + const ident = node.declarations[0].init.arguments[0].name; + if (!ident.startsWith('_sfc_main')) return; + if (node.declarations[0].init.arguments[1].type !== 'ArrayExpression') return; + if (node.declarations[0].init.arguments[1].elements.length === 0) return; + const __cssModulesIndex = node.declarations[0].init.arguments[1].elements.findIndex((x) => { + if (x?.type !== 'ArrayExpression') return false; + if (x.elements.length !== 2) return false; + if (x.elements[0]?.type !== 'Literal') return false; + if (x.elements[0].value !== '__cssModules') return false; + if (x.elements[1]?.type !== 'Identifier') return false; + return true; + }); + if (!~__cssModulesIndex) return; + const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name; + const cssModuleForestNode = parent.body.find((x) => { + if (x.type !== 'VariableDeclaration') return false; + if (x.declarations.length !== 1) return false; + if (x.declarations[0].id.type !== 'Identifier') return false; + if (x.declarations[0].id.name !== cssModuleForestName) return false; + if (x.declarations[0].init?.type !== 'ObjectExpression') return false; + return true; + }) as unknown as estree.VariableDeclaration; + const moduleForest = new Map((cssModuleForestNode.declarations[0].init as estree.ObjectExpression).properties.flatMap((property) => { + if (property.type !== 'Property') return []; + if (property.key.type !== 'Literal') return []; + if (property.value.type !== 'Identifier') return []; + return [[property.key.value as string, property.value.name as string]]; + })); + const sfcMain = parent.body.find((x) => { + if (x.type !== 'VariableDeclaration') return false; + if (x.declarations.length !== 1) return false; + if (x.declarations[0].id.type !== 'Identifier') return false; + if (x.declarations[0].id.name !== ident) return false; + return true; + }) as unknown as estree.VariableDeclaration; + if (sfcMain.declarations[0].init?.type !== 'CallExpression') return; + if (sfcMain.declarations[0].init.callee.type !== 'Identifier') return; + if (sfcMain.declarations[0].init.callee.name !== 'defineComponent') return; + if (sfcMain.declarations[0].init.arguments.length !== 1) return; + if (sfcMain.declarations[0].init.arguments[0].type !== 'ObjectExpression') return; + const setup = sfcMain.declarations[0].init.arguments[0].properties.find((x) => { + if (x.type !== 'Property') return false; + if (x.key.type !== 'Identifier') return false; + if (x.key.name !== 'setup') return false; + return true; + }) as unknown as estree.Property; + if (setup.value.type !== 'FunctionExpression') return; + const render = setup.value.body.body.find((x) => { + if (x.type !== 'ReturnStatement') return false; + return true; + }) as unknown as estree.ReturnStatement; + if (render.argument?.type !== 'ArrowFunctionExpression') return; + if (render.argument.params.length !== 2) return; + const ctx = render.argument.params[0]; + if (ctx.type !== 'Identifier') return; + if (ctx.name !== '_ctx') return; + if (render.argument.body.type !== 'BlockStatement') return; + for (const [key, value] of moduleForest) { + const cssModuleTreeNode = parent.body.find((x) => { + if (x.type !== 'VariableDeclaration') return false; + if (x.declarations.length !== 1) return false; + if (x.declarations[0].id.type !== 'Identifier') return false; + if (x.declarations[0].id.name !== value) return false; + return true; + }) as unknown as estree.VariableDeclaration; + if (cssModuleTreeNode.declarations[0].init?.type !== 'ObjectExpression') return; + const moduleTree = new Map(cssModuleTreeNode.declarations[0].init.properties.flatMap((property) => { + if (property.type !== 'Property') return []; + const actualKey = property.key.type === 'Identifier' ? property.key.name : property.key.type === 'Literal' ? property.key.value : null; + if (typeof actualKey !== 'string') return []; + if (property.value.type === 'Literal') return [[actualKey, property.value.value as string]]; + if (property.value.type !== 'Identifier') return []; + const labelledValue = property.value.name; + const actualValue = parent.body.find((x) => { + if (x.type !== 'VariableDeclaration') return false; + if (x.declarations.length !== 1) return false; + if (x.declarations[0].id.type !== 'Identifier') return false; + if (x.declarations[0].id.name !== labelledValue) return false; + return true; + }) as unknown as estree.VariableDeclaration; + if (actualValue.declarations[0].init?.type !== 'Literal') return []; + return [[actualKey, actualValue.declarations[0].init.value as string]]; + })); + (walk as typeof estreeWalker.walk)(render.argument.body, { + enter(childNode) { + if (childNode.type !== 'MemberExpression') return; + if (childNode.object.type !== 'MemberExpression') return; + if (childNode.object.object.type !== 'Identifier') return; + if (childNode.object.object.name !== ctx.name) return; + if (childNode.object.property.type !== 'Identifier') return; + if (childNode.object.property.name !== key) return; + if (childNode.property.type !== 'Identifier') return; + const actualValue = moduleTree.get(childNode.property.name); + if (actualValue === undefined) return; + this.replace({ + type: 'Literal', + value: actualValue, + }); + }, + }); + (walk as typeof estreeWalker.walk)(render.argument.body, { + enter(childNode) { + if (childNode.type !== 'MemberExpression') return; + if (childNode.object.type !== 'MemberExpression') return; + if (childNode.object.object.type !== 'Identifier') return; + if (childNode.object.object.name !== ctx.name) return; + if (childNode.object.property.type !== 'Identifier') return; + if (childNode.object.property.name !== key) return; + if (childNode.property.type !== 'Identifier') return; + console.error(`Undefined style detected: ${key}.${childNode.property.name} (in ${name})`); + this.replace({ + type: 'Identifier', + name: 'undefined', + }); + }, + }); + (walk as typeof estreeWalker.walk)(render.argument.body, { + enter(childNode) { + if (childNode.type !== 'CallExpression') return; + if (childNode.callee.type !== 'Identifier') return; + if (childNode.callee.name !== 'normalizeClass') return; + if (childNode.arguments.length !== 1) return; + const normalized = normalizeClass(childNode.arguments[0]); + if (normalized === null) return; + this.replace({ + type: 'Literal', + value: normalized, + }); + }, + }); + } + if (node.declarations[0].init.arguments[1].elements.length === 1) { + this.replace({ + type: 'VariableDeclaration', + declarations: [{ + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: node.declarations[0].id.name, + }, + init: { + type: 'Identifier', + name: ident, + }, + }], + kind: 'const', + }); + } else { + this.replace({ + type: 'VariableDeclaration', + declarations: [{ + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: node.declarations[0].id.name, + }, + init: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: '_export_sfc', + }, + arguments: [{ + type: 'Identifier', + name: ident, + }, { + type: 'ArrayExpression', + elements: node.declarations[0].init.arguments[1].elements.slice(0, __cssModulesIndex).concat(node.declarations[0].init.arguments[1].elements.slice(__cssModulesIndex + 1)), + }], + }, + }], + kind: 'const', + }); + } + }, + }); +} + +// eslint-disable-next-line import/no-default-export +export default function pluginUnwindCssModuleClassName(): Plugin { + return { + name: 'UnwindCssModuleClassName', + renderChunk(code): { code: string } { + const ast = this.parse(code) as unknown as estree.Node; + unwindCssModuleClassName(ast); + return { code: generate(ast) }; + }, + }; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5b4004d8e3..506d187901 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,26 +19,28 @@ "@rollup/plugin-json": "6.0.0", "@rollup/plugin-replace": "5.0.2", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.13.2", - "@tabler/icons-webfont": "2.17.0", - "@vitejs/plugin-vue": "4.2.2", - "@vue-macros/reactivity-transform": "0.3.6", - "@vue/compiler-sfc": "3.3.1", - "autosize": "5.0.2", - "blurhash": "2.0.5", - "broadcast-channel": "4.20.2", + "@syuilo/aiscript": "0.13.3", + "@tabler/icons-webfont": "2.21.0", + "@vitejs/plugin-vue": "4.2.3", + "@vue-macros/reactivity-transform": "0.3.9", + "@vue/compiler-sfc": "3.3.4", + "astring": "1.8.6", + "autosize": "6.0.1", + "broadcast-channel": "5.1.0", "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", + "buraha": "github:misskey-dev/buraha", "canvas-confetti": "1.6.0", "chart.js": "4.3.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "6.17.4", + "chromatic": "6.18.0", "compare-versions": "5.0.3", "cropperjs": "2.0.0-beta.2", "date-fns": "2.30.0", "escape-regexp": "0.0.1", + "estree-walker": "^3.0.3", "eventemitter3": "5.0.1", "gsap": "3.11.5", "idb-keyval": "6.2.1", @@ -53,7 +55,7 @@ "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.21.6", + "rollup": "3.23.0", "s-age": "1.1.2", "sanitize-html": "2.10.0", "sass": "1.62.1", @@ -61,71 +63,70 @@ "strict-event-emitter-types": "2.0.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.151.3", + "three": "0.153.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.6", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "5.0.4", + "typescript": "5.1.3", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.3.5", - "vue": "3.3.1", - "vue-plyr": "7.0.0", + "vite": "4.3.9", + "vue": "3.3.4", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-actions": "7.0.10", - "@storybook/addon-essentials": "7.0.10", - "@storybook/addon-interactions": "7.0.10", - "@storybook/addon-links": "7.0.10", - "@storybook/addon-storysource": "7.0.10", - "@storybook/addons": "7.0.10", - "@storybook/blocks": "7.0.10", - "@storybook/core-events": "7.0.10", + "@storybook/addon-actions": "7.0.18", + "@storybook/addon-essentials": "7.0.18", + "@storybook/addon-interactions": "7.0.18", + "@storybook/addon-links": "7.0.18", + "@storybook/addon-storysource": "7.0.18", + "@storybook/addons": "7.0.18", + "@storybook/blocks": "7.0.18", + "@storybook/core-events": "7.0.18", "@storybook/jest": "0.1.0", - "@storybook/manager-api": "7.0.10", - "@storybook/preview-api": "7.0.10", - "@storybook/react": "7.0.10", - "@storybook/react-vite": "7.0.10", + "@storybook/manager-api": "7.0.18", + "@storybook/preview-api": "7.0.18", + "@storybook/react": "7.0.18", + "@storybook/react-vite": "7.0.18", "@storybook/testing-library": "0.1.0", - "@storybook/theming": "7.0.10", - "@storybook/types": "7.0.10", - "@storybook/vue3": "7.0.10", - "@storybook/vue3-vite": "7.0.10", + "@storybook/theming": "7.0.18", + "@storybook/types": "7.0.18", + "@storybook/vue3": "7.0.18", + "@storybook/vue3-vite": "7.0.18", "@testing-library/jest-dom": "5.16.5", "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", "@types/estree": "1.0.1", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.2", - "@types/matter-js": "0.18.3", + "@types/matter-js": "0.18.5", "@types/micromatch": "4.0.2", - "@types/node": "20.1.3", + "@types/node": "20.2.5", "@types/punycode": "2.1.0", "@types/sanitize-html": "2.9.0", "@types/seedrandom": "3.0.5", - "@types/testing-library__jest-dom": "^5.14.5", + "@types/testing-library__jest-dom": "^5.14.6", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.5", - "@typescript-eslint/parser": "5.59.5", - "@vitest/coverage-c8": "0.31.0", - "@vue/runtime-core": "3.3.1", - "astring": "1.8.4", + "@typescript-eslint/eslint-plugin": "5.59.8", + "@typescript-eslint/parser": "5.59.8", + "@vitest/coverage-c8": "0.31.4", + "@vue/runtime-core": "3.3.4", + "acorn": "^8.8.2", "chokidar-cli": "3.0.0", "cross-env": "7.0.3", - "cypress": "12.12.0", - "eslint": "8.40.0", + "cypress": "12.13.0", + "eslint": "8.41.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.12.0", + "eslint-plugin-vue": "9.14.1", "fast-glob": "3.2.12", - "happy-dom": "9.16.0", + "happy-dom": "9.20.3", "micromatch": "3.1.10", "msw": "1.2.1", "msw-storybook-addon": "1.8.0", @@ -133,13 +134,13 @@ "react": "18.2.0", "react-dom": "18.2.0", "start-server-and-test": "2.0.0", - "storybook": "7.0.10", + "storybook": "7.0.18", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", "vite-plugin-turbosnap": "1.0.2", - "vitest": "0.31.0", + "vitest": "0.31.4", "vitest-fetch-mock": "0.2.2", - "vue-eslint-parser": "9.2.1", - "vue-tsc": "1.6.4" + "vue-eslint-parser": "9.3.0", + "vue-tsc": "1.6.5" } } diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts new file mode 100644 index 0000000000..921c161765 --- /dev/null +++ b/packages/frontend/src/_boot_.ts @@ -0,0 +1,14 @@ +// https://vitejs.dev/config/build-options.html#build-modulepreload +import 'vite/modulepreload-polyfill'; + +import '@/style.scss'; +import { mainBoot } from './boot/main-boot'; +import { subBoot } from './boot/sub-boot'; + +const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete']; + +if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) { + subBoot(); +} else { + mainBoot(); +} diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 9b104391d7..4770f616ac 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -3,11 +3,11 @@ import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; import { miLocalStorage } from './local-storage'; +import { MenuButton } from './types/menu'; import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; -import { MenuButton } from './types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -101,57 +101,57 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr 'Content-Type': 'application/json', }, }) - .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => { - if (res.status >= 500 && res.status < 600) { + .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { // サーバーエラー(5xx)の場合をrejectとする // (認証エラーなど4xxはresolve) - return fail2(res); - } - res.json().then(done2, fail2); - })) - .then(async res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { // SUSPENDED - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await showSuspendedDialog(); - } - } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { // USER_IS_DELETED // アカウントが削除されている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.accountDeleted, - text: i18n.ts.accountDeletedDescription, - }); - } - } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { // AUTHENTICATION_FAILED // トークンが無効化されていたりアカウントが削除されたりしている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, + }); + } + } else { await alert({ type: 'error', - title: i18n.ts.tokenRevoked, - text: i18n.ts.tokenRevokedDescription, + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), }); } + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); } else { - await alert({ - type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), - }); + (res as Account).token = token; + done(res as Account); } - - // rejectかつ理由がtrueの場合、削除対象であることを示す - fail(true); - } else { - (res as Account).token = token; - done(res as Account); - } - }) - .catch(fail); + }) + .catch(fail); }); } @@ -305,3 +305,7 @@ export async function openAccountMenu(opts: { }); } } + +if (_DEV_) { + (window as any).$i = $i; +} diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts new file mode 100644 index 0000000000..e1b12fe7d6 --- /dev/null +++ b/packages/frontend/src/boot/common.ts @@ -0,0 +1,262 @@ +import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent, App } from 'vue'; +import { compareVersions } from 'compare-versions'; +import widgets from '@/widgets'; +import directives from '@/directives'; +import components from '@/components'; +import { version, ui, lang, updateLocale } from '@/config'; +import { applyTheme } from '@/scripts/theme'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { i18n, updateI18n } from '@/i18n'; +import { confirm, alert, post, popup, toast } from '@/os'; +import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; +import { defaultStore, ColdDeviceStorage } from '@/store'; +import { fetchInstance, instance } from '@/instance'; +import { deviceKind } from '@/scripts/device-kind'; +import { reloadChannel } from '@/scripts/unison-reload'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { getUrlWithoutLoginId } from '@/scripts/login-id'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { deckStore } from '@/ui/deck/deck-store'; +import { miLocalStorage } from '@/local-storage'; +import { fetchCustomEmojis } from '@/custom-emojis'; +import { mainRouter } from '@/router'; + +export async function common(createVue: () => App<Element>) { + console.info(`Misskey v${version}`); + + if (_DEV_) { + console.warn('Development mode!!!'); + + console.info(`vue ${vueVersion}`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).$i = $i; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).$store = defaultStore; + + window.addEventListener('error', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled error', + text: event.message + }); + */ + }); + + window.addEventListener('unhandledrejection', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled promise rejection', + text: event.reason + }); + */ + }); + } + + const splash = document.getElementById('splash'); + // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) + if (splash) splash.addEventListener('transitionend', () => { + splash.remove(); + }); + + let isClientUpdated = false; + + //#region クライアントが更新されたかチェック + const lastVersion = miLocalStorage.getItem('lastVersion'); + if (lastVersion !== version) { + miLocalStorage.setItem('lastVersion', version); + + // テーマリビルドするため + miLocalStorage.removeItem('theme'); + + try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため + if (lastVersion != null && compareVersions(version, lastVersion) === 1) { + isClientUpdated = true; + } + } catch (err) { /* empty */ } + } + //#endregion + + //#region Detect language & fetch translations + const localeVersion = miLocalStorage.getItem('localeVersion'); + const localeOutdated = (localeVersion == null || localeVersion !== version); + if (localeOutdated) { + const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); + if (res.status === 200) { + const newLocale = await res.text(); + const parsedNewLocale = JSON.parse(newLocale); + miLocalStorage.setItem('locale', newLocale); + miLocalStorage.setItem('localeVersion', version); + updateLocale(parsedNewLocale); + updateI18n(parsedNewLocale); + } + } + //#endregion + + // タッチデバイスでCSSの:hoverを機能させる + document.addEventListener('touchend', () => {}, { passive: true }); + + // 一斉リロード + reloadChannel.addEventListener('message', path => { + if (path !== null) location.href = path; + else location.reload(); + }); + + // If mobile, insert the viewport meta tag + if (['smartphone', 'tablet'].includes(deviceKind)) { + const viewport = document.getElementsByName('viewport').item(0); + viewport.setAttribute('content', + `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); + } + + //#region Set lang attr + const html = document.documentElement; + html.setAttribute('lang', lang); + //#endregion + + await defaultStore.ready; + await deckStore.ready; + + const fetchInstanceMetaPromise = fetchInstance(); + + fetchInstanceMetaPromise.then(() => { + miLocalStorage.setItem('v', instance.version); + }); + + //#region loginId + const params = new URLSearchParams(location.search); + const loginId = params.get('loginId'); + + if (loginId) { + const target = getUrlWithoutLoginId(location.href); + + if (!$i || $i.id !== loginId) { + const account = await getAccountFromId(loginId); + if (account) { + await login(account.token, target); + } + } + + history.replaceState({ misskey: 'loginId' }, '', target); + } + //#endregion + + // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) + watch(defaultStore.reactiveState.darkMode, (darkMode) => { + applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + }, { immediate: miLocalStorage.getItem('theme') == null }); + + const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); + const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); + + watch(darkTheme, (theme) => { + if (defaultStore.state.darkMode) { + applyTheme(theme); + } + }); + + watch(lightTheme, (theme) => { + if (!defaultStore.state.darkMode) { + applyTheme(theme); + } + }); + + //#region Sync dark mode + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } + + window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', mql.matches); + } + }); + //#endregion + + fetchInstanceMetaPromise.then(() => { + if (defaultStore.state.themeInitial) { + if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); + if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); + defaultStore.set('themeInitial', false); + } + }); + + watch(defaultStore.reactiveState.useBlurEffectForModal, v => { + document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); + }, { immediate: true }); + + watch(defaultStore.reactiveState.useBlurEffect, v => { + if (v) { + document.documentElement.style.removeProperty('--blur'); + } else { + document.documentElement.style.setProperty('--blur', 'none'); + } + }, { immediate: true }); + + //#region Fetch user + if ($i && $i.token) { + if (_DEV_) { + console.log('account cache found. refreshing...'); + } + + refreshAccount(); + } + //#endregion + + try { + await fetchCustomEmojis(); + } catch (err) { /* empty */ } + + const app = createVue(); + + if (_DEV_) { + app.config.performance = true; + } + + widgets(app); + directives(app); + components(app); + + // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 + // なぜか2回実行されることがあるため、mountするdivを1つに制限する + const rootEl = ((): HTMLElement => { + const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; + + const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + + if (currentRoot) { + console.warn('multiple import detected'); + return currentRoot; + } + + const root = document.createElement('div'); + root.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(root); + return root; + })(); + + app.mount(rootEl); + + // boot.jsのやつを解除 + window.onerror = null; + window.onunhandledrejection = null; + + removeSplash(); + + return { + isClientUpdated, + app, + }; +} + +function removeSplash() { + const splash = document.getElementById('splash'); + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + } +} diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts new file mode 100644 index 0000000000..76e8c50724 --- /dev/null +++ b/packages/frontend/src/boot/main-boot.ts @@ -0,0 +1,254 @@ +import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; +import { common } from './common'; +import { version, ui, lang, updateLocale } from '@/config'; +import { i18n, updateI18n } from '@/i18n'; +import { confirm, alert, post, popup, toast } from '@/os'; +import { useStream } from '@/stream'; +import * as sound from '@/scripts/sound'; +import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; +import { defaultStore, ColdDeviceStorage } from '@/store'; +import { makeHotkey } from '@/scripts/hotkey'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { miLocalStorage } from '@/local-storage'; +import { claimAchievement, claimedAchievements } from '@/scripts/achievements'; +import { mainRouter } from '@/router'; +import { initializeSw } from '@/scripts/initialize-sw'; + +export async function mainBoot() { + const { isClientUpdated } = await common(() => createApp( + new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : + ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : + ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : + defineAsyncComponent(() => import('@/ui/universal.vue')), + )); + + reactionPicker.init(); + + if (isClientUpdated && $i) { + popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); + } + + const stream = useStream(); + + let reloadDialogShowing = false; + stream.on('_disconnected_', async () => { + if (defaultStore.state.serverDisconnectedBehavior === 'reload') { + location.reload(); + } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await confirm({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }); + reloadDialogShowing = false; + if (!canceled) { + location.reload(); + } + } + }); + + for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { + import('../plugin').then(async ({ install }) => { + // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 + await new Promise(r => setTimeout(r, 0)); + install(plugin); + }); + } + + const hotkeys = { + 'd': (): void => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': (): void => { + mainRouter.push('/search'); + }, + }; + + if ($i) { + // only add post shortcuts if logged in + hotkeys['p|n'] = post; + + defaultStore.loaded.then(() => { + if (defaultStore.state.accountSetupWizard !== -1) { + popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed'); + } + }); + + if ($i.isDeleted) { + alert({ + type: 'warning', + text: i18n.ts.accountDeletionInProgress, + }); + } + + const now = new Date(); + const m = now.getMonth() + 1; + const d = now.getDate(); + + if ($i.birthday) { + const bm = parseInt($i.birthday.split('-')[1]); + const bd = parseInt($i.birthday.split('-')[2]); + if (m === bm && d === bd) { + claimAchievement('loggedInOnBirthday'); + } + } + + if (m === 1 && d === 1) { + claimAchievement('loggedInOnNewYearsDay'); + } + + if ($i.loggedInDays >= 3) claimAchievement('login3'); + if ($i.loggedInDays >= 7) claimAchievement('login7'); + if ($i.loggedInDays >= 15) claimAchievement('login15'); + if ($i.loggedInDays >= 30) claimAchievement('login30'); + if ($i.loggedInDays >= 60) claimAchievement('login60'); + if ($i.loggedInDays >= 100) claimAchievement('login100'); + if ($i.loggedInDays >= 200) claimAchievement('login200'); + if ($i.loggedInDays >= 300) claimAchievement('login300'); + if ($i.loggedInDays >= 400) claimAchievement('login400'); + if ($i.loggedInDays >= 500) claimAchievement('login500'); + if ($i.loggedInDays >= 600) claimAchievement('login600'); + if ($i.loggedInDays >= 700) claimAchievement('login700'); + if ($i.loggedInDays >= 800) claimAchievement('login800'); + if ($i.loggedInDays >= 900) claimAchievement('login900'); + if ($i.loggedInDays >= 1000) claimAchievement('login1000'); + + if ($i.notesCount > 0) claimAchievement('notes1'); + if ($i.notesCount >= 10) claimAchievement('notes10'); + if ($i.notesCount >= 100) claimAchievement('notes100'); + if ($i.notesCount >= 500) claimAchievement('notes500'); + if ($i.notesCount >= 1000) claimAchievement('notes1000'); + if ($i.notesCount >= 5000) claimAchievement('notes5000'); + if ($i.notesCount >= 10000) claimAchievement('notes10000'); + if ($i.notesCount >= 20000) claimAchievement('notes20000'); + if ($i.notesCount >= 30000) claimAchievement('notes30000'); + if ($i.notesCount >= 40000) claimAchievement('notes40000'); + if ($i.notesCount >= 50000) claimAchievement('notes50000'); + if ($i.notesCount >= 60000) claimAchievement('notes60000'); + if ($i.notesCount >= 70000) claimAchievement('notes70000'); + if ($i.notesCount >= 80000) claimAchievement('notes80000'); + if ($i.notesCount >= 90000) claimAchievement('notes90000'); + if ($i.notesCount >= 100000) claimAchievement('notes100000'); + + if ($i.followersCount > 0) claimAchievement('followers1'); + if ($i.followersCount >= 10) claimAchievement('followers10'); + if ($i.followersCount >= 50) claimAchievement('followers50'); + if ($i.followersCount >= 100) claimAchievement('followers100'); + if ($i.followersCount >= 300) claimAchievement('followers300'); + if ($i.followersCount >= 500) claimAchievement('followers500'); + if ($i.followersCount >= 1000) claimAchievement('followers1000'); + + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) { + claimAchievement('passedSinceAccountCreated1'); + } + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) { + claimAchievement('passedSinceAccountCreated2'); + } + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) { + claimAchievement('passedSinceAccountCreated3'); + } + + if (claimedAchievements.length >= 30) { + claimAchievement('collectAchievements30'); + } + + window.setInterval(() => { + if (Math.floor(Math.random() * 20000) === 0) { + claimAchievement('justPlainLucky'); + } + }, 1000 * 10); + + window.setTimeout(() => { + claimAchievement('client30min'); + }, 1000 * 60 * 30); + + window.setTimeout(() => { + claimAchievement('client60min'); + }, 1000 * 60 * 60); + + const lastUsed = miLocalStorage.getItem('lastUsed'); + if (lastUsed) { + const lastUsedDate = parseInt(lastUsed, 10); + // 二時間以上前なら + if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { + toast(i18n.t('welcomeBackWithName', { + name: $i.name || $i.username, + })); + } + } + miLocalStorage.setItem('lastUsed', Date.now().toString()); + + const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); + const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); + if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { + if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { + popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed'); + } + } + + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + } + + const main = markRaw(stream.useChannel('main', null, 'System')); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateAccount(i); + }); + + main.on('readAllNotifications', () => { + updateAccount({ hasUnreadNotification: false }); + }); + + main.on('unreadNotification', () => { + updateAccount({ hasUnreadNotification: true }); + }); + + main.on('unreadMention', () => { + updateAccount({ hasUnreadMentions: true }); + }); + + main.on('readAllUnreadMentions', () => { + updateAccount({ hasUnreadMentions: false }); + }); + + main.on('unreadSpecifiedNote', () => { + updateAccount({ hasUnreadSpecifiedNotes: true }); + }); + + main.on('readAllUnreadSpecifiedNotes', () => { + updateAccount({ hasUnreadSpecifiedNotes: false }); + }); + + main.on('readAllAntennas', () => { + updateAccount({ hasUnreadAntenna: false }); + }); + + main.on('unreadAntenna', () => { + updateAccount({ hasUnreadAntenna: true }); + sound.play('antenna'); + }); + + main.on('readAllAnnouncements', () => { + updateAccount({ hasUnreadAnnouncement: false }); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + signout(); + }); + } + + // shortcut + document.addEventListener('keydown', makeHotkey(hotkeys)); + + initializeSw(); +} diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts new file mode 100644 index 0000000000..c2664f6c1d --- /dev/null +++ b/packages/frontend/src/boot/sub-boot.ts @@ -0,0 +1,8 @@ +import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; +import { common } from './common'; + +export async function subBoot() { + const { isClientUpdated } = await common(() => createApp( + defineAsyncComponent(() => import('@/ui/minimum.vue')), + )); +} diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index 9f2bf99338..48236782d9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -1,5 +1,5 @@ <template> -<MkWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> +<MkWindow ref="uiWindow" :initialWidth="400" :initialHeight="500" :canResize="true" @closed="emit('closed')"> <template #header> <i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i> <I18n :src="i18n.ts.reportAbuseOf" tag="span"> @@ -8,8 +8,8 @@ </template> </I18n> </template> - <MkSpacer :margin-min="20" :margin-max="28"> - <div class="dpvffvvy _gaps_m"> + <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_gaps_m" :class="$style.root"> <div class=""> <MkTextarea v-model="comment"> <template #label>{{ i18n.ts.details }}</template> @@ -60,8 +60,8 @@ function send() { } </script> -<style lang="scss" scoped> -.dpvffvvy { +<style lang="scss" module> +.root { --root-margin: 16px; } </style> diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index b02bfdc2b8..bc07b9ba5f 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -7,11 +7,11 @@ </template> <script lang="ts" setup> +import { ref } from 'vue'; +import { UserLite } from 'misskey-js/built/entities'; import MkMention from './MkMention.vue'; import { i18n } from '@/i18n'; import { host as localHost } from '@/config'; -import { ref } from 'vue'; -import { UserLite } from 'misskey-js/built/entities'; import { api } from '@/os'; const user = ref<UserLite>(); diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index d30037dcf9..3fdb261dac 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -3,7 +3,14 @@ <div v-if="achievements" :class="$style.root"> <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel"> <div :class="$style.icon"> - <div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]"> + <div + :class="[$style.iconFrame, { + [$style.iconFrame_bronze]: ACHIEVEMENT_BADGES[achievement.name].frame === 'bronze', + [$style.iconFrame_silver]: ACHIEVEMENT_BADGES[achievement.name].frame === 'silver', + [$style.iconFrame_gold]: ACHIEVEMENT_BADGES[achievement.name].frame === 'gold', + [$style.iconFrame_platinum]: ACHIEVEMENT_BADGES[achievement.name].frame === 'platinum', + }]" + > <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }"> <img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img"> </div> diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index e7fbb47284..0aebdccf4f 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; import MkAnalogClock from './MkAnalogClock.vue'; -import isChromatic from 'chromatic'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index f12020f810..05caffe7d0 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -39,6 +39,7 @@ --> <line + ref="sLine" :class="[$style.s, { [$style.animate]: !disableSAnimate && sAnimation !== 'none', [$style.elastic]: sAnimation === 'elastic', [$style.easeOut]: sAnimation === 'easeOut' }]" :x1="5 - (0 * (sHandLengthRatio * handsTailLength))" :y1="5 + (1 * (sHandLengthRatio * handsTailLength))" @@ -73,9 +74,10 @@ </template> <script lang="ts" setup> -import { computed, onMounted, onBeforeUnmount } from 'vue'; +import { computed, onMounted, onBeforeUnmount, ref } from 'vue'; import tinycolor from 'tinycolor2'; import { globalEvents } from '@/events.js'; +import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles const angleDiff = (a: number, b: number) => { @@ -145,6 +147,7 @@ let mAngle = $ref<number>(0); let sAngle = $ref<number>(0); let disableSAnimate = $ref(false); let sOneRound = false; +const sLine = ref<SVGPathElement>(); function tick() { const now = props.now(); @@ -160,17 +163,21 @@ function tick() { } hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); mAngle = Math.PI * (m + s / 60) / 30; - if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない) + if (sOneRound && sLine.value) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない) sAngle = Math.PI * 60 / 30; - window.setTimeout(() => { + defaultIdlingRenderScheduler.delete(tick); + sLine.value.addEventListener('transitionend', () => { disableSAnimate = true; - window.setTimeout(() => { + requestAnimationFrame(() => { sAngle = 0; - window.setTimeout(() => { + requestAnimationFrame(() => { disableSAnimate = false; - }, 100); - }, 100); - }, 700); + if (enabled) { + defaultIdlingRenderScheduler.add(tick); + } + }); + }); + }, { once: true }); } else { sAngle = Math.PI * s / 30; } @@ -194,20 +201,13 @@ function calcColors() { calcColors(); onMounted(() => { - const update = () => { - if (enabled) { - tick(); - window.setTimeout(update, 1000); - } - }; - update(); - + defaultIdlingRenderScheduler.add(tick); globalEvents.on('themeChanged', calcColors); }); onBeforeUnmount(() => { enabled = false; - + defaultIdlingRenderScheduler.delete(tick); globalEvents.off('themeChanged', calcColors); }); </script> diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue new file mode 100644 index 0000000000..575ea7c5e3 --- /dev/null +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -0,0 +1,243 @@ +<template> +<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, shallowRef } from 'vue'; +import isChromatic from 'chromatic/isChromatic'; + +const canvasEl = shallowRef<HTMLCanvasElement>(); + +const props = withDefaults(defineProps<{ + scale?: number; + focus?: number; +}>(), { + scale: 1.0, + focus: 1.0, +}); + +function loadShader(gl, type, source) { + const shader = gl.createShader(type); + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + alert( + `falied to compile shader: ${gl.getShaderInfoLog(shader)}`, + ); + gl.deleteShader(shader); + return null; + } + + return shader; +} + +function initShaderProgram(gl, vsSource, fsSource) { + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); + + const shaderProgram = gl.createProgram(); + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + alert( + `failed to init shader: ${gl.getProgramInfoLog( + shaderProgram, + )}`, + ); + return null; + } + + return shaderProgram; +} + +let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null; + +onMounted(() => { + const canvas = canvasEl.value!; + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + + const gl = canvas.getContext('webgl', { premultipliedAlpha: true }); + if (gl == null) return; + + gl.clearColor(0.0, 0.0, 0.0, 0.0); + gl.clear(gl.COLOR_BUFFER_BIT); + + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + + const shaderProgram = initShaderProgram(gl, ` + attribute vec2 vertex; + + uniform vec2 u_scale; + + varying vec2 v_pos; + + void main() { + gl_Position = vec4(vertex, 0.0, 1.0); + v_pos = vertex / u_scale; + } + `, ` + precision mediump float; + + vec3 mod289(vec3 x) { + return x - floor(x * (1.0 / 289.0)) * 289.0; + } + + vec2 mod289(vec2 x) { + return x - floor(x * (1.0 / 289.0)) * 289.0; + } + + vec3 permute(vec3 x) { + return mod289(((x*34.0)+1.0)*x); + } + + float snoise(vec2 v) { + const vec4 C = vec4(0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + + vec2 i = floor(v + dot(v, C.yy) ); + vec2 x0 = v - i + dot(i, C.xx); + + vec2 i1; + i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + + i = mod289(i); + vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 )) + + i.x + vec3(0.0, i1.x, 1.0 )); + + vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); + m = m*m ; + m = m*m ; + + vec3 x = 2.0 * fract(p * C.www) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + + m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h ); + + vec3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); + } + + uniform float u_time; + uniform vec2 u_resolution; + uniform float u_spread; + uniform float u_speed; + uniform float u_warp; + uniform float u_focus; + uniform float u_itensity; + + varying vec2 v_pos; + + float circle( in vec2 _pos, in vec2 _origin, in float _radius ) { + float SPREAD = 0.7 * u_spread; + float SPEED = 0.00055 * u_speed; + float WARP = 1.5 * u_warp; + float FOCUS = 1.15 * u_focus; + + vec2 dist = _pos - _origin; + + float distortion = snoise( vec2( + _pos.x * 1.587 * WARP + u_time * SPEED * 0.5, + _pos.y * 1.192 * WARP + u_time * SPEED * 0.3 + ) ) * 0.5 + 0.5; + + float feather = 0.01 + SPREAD * pow( distortion, FOCUS ); + + return 1.0 - smoothstep( + _radius - ( _radius * feather ), + _radius + ( _radius * feather ), + dot( dist, dist ) * 4.0 + ); + } + + void main() { + vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 ); + vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 ); + vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 ); + + float ratio = u_resolution.x / u_resolution.y; + + vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5; + + vec3 color = vec3( 0.0 ); + + float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5; + float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5; + float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5; + + float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 ); + float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 ); + float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 ); + + color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix ); + color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix ); + color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix ); + + color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 ); + + vec3 inverted = vec3( 1.0 ) - color; + gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) ); + } + `); + + gl.useProgram(shaderProgram); + const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution'); + const u_time = gl.getUniformLocation(shaderProgram, 'u_time'); + const u_spread = gl.getUniformLocation(shaderProgram, 'u_spread'); + const u_speed = gl.getUniformLocation(shaderProgram, 'u_speed'); + const u_warp = gl.getUniformLocation(shaderProgram, 'u_warp'); + const u_focus = gl.getUniformLocation(shaderProgram, 'u_focus'); + const u_itensity = gl.getUniformLocation(shaderProgram, 'u_itensity'); + const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale'); + gl.uniform2fv(u_resolution, [canvas.width, canvas.height]); + gl.uniform1f(u_spread, 1.0); + gl.uniform1f(u_speed, 1.0); + gl.uniform1f(u_warp, 1.0); + gl.uniform1f(u_focus, props.focus); + gl.uniform1f(u_itensity, 0.5); + gl.uniform2fv(u_scale, [props.scale, props.scale]); + + const vertex = gl.getAttribLocation(shaderProgram, 'vertex'); + gl.enableVertexAttribArray(vertex); + gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0); + + const vertices = [1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0]; + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); + + if (isChromatic()) { + gl!.uniform1f(u_time, 0); + gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); + } else { + function render(timeStamp) { + gl!.uniform1f(u_time, timeStamp); + gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); + + handle = window.requestAnimationFrame(render); + } + + handle = window.requestAnimationFrame(render); + } +}); + +onUnmounted(() => { + if (handle) { + window.cancelAnimationFrame(handle); + } +}); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 6ade5316c6..8bfcfa6aa6 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -11,29 +11,29 @@ <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }"> <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> </div> - <MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate"> + <MkSwitch v-else-if="c.type === 'switch'" :modelValue="valueForSwitch" @update:modelValue="onSwitchUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkSwitch> - <MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput"> + <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkTextarea> - <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput"> + <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput"> + <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> </MkSelect> <MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton> - <MkFolder v-else-if="c.type === 'folder'" :default-open="c.opened"> + <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> <template #label>{{ c.title }}</template> <template v-for="child in c.children" :key="child"> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 663c57623d..fd892d8174 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -10,7 +10,7 @@ </li> <li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> </ol> - <ol v-else-if="hashtags.length > 0" ref="suggests" :class="[$style.list, $style.hashtags]"> + <ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list"> <li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown"> <span class="name">{{ hashtag }}</span> </li> @@ -42,7 +42,7 @@ import { acct } from '@/filters/user'; import * as os from '@/os'; import { MFM_TAGS } from '@/scripts/mfm-tags'; import { defaultStore } from '@/store'; -import { emojilist } from '@/scripts/emojilist'; +import { emojilist, getEmojiName } from '@/scripts/emojilist'; import { i18n } from '@/i18n'; import { miLocalStorage } from '@/local-storage'; import { customEmojis } from '@/custom-emojis'; @@ -71,14 +71,14 @@ const emojiDb = computed(() => { url: char2path(x.char), })); - for (const x of lib) { - if (x.keywords) { - for (const k of x.keywords) { + for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const [emoji, keywords] of Object.entries(index)) { + for (const k of keywords) { unicodeEmojiDB.push({ - emoji: x.char, + emoji: emoji, name: k, - aliasOf: x.name, - url: char2path(x.char), + aliasOf: getEmojiName(emoji)!, + url: char2path(emoji), }); } } diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 995a72e511..630620fc08 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -1,7 +1,7 @@ <template> <div> <div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> - <MkAvatar :user="user" style="width:32px;height:32px;" indicator link preview/> + <MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/> </div> </div> </template> diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 0ddee34f0a..16e44ec618 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -2,23 +2,23 @@ <button v-if="!link" ref="el" class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.asLike]: asLike }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" :type="type" @click="emit('click', $event)" @mousedown="onMousedown" > - <div ref="ripples" :class="$style.ripples"></div> + <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div> <div :class="$style.content"> <slot></slot> </div> </button> <MkA v-else class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.asLike]: asLike }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" :to="to" @mousedown="onMousedown" > - <div ref="ripples" :class="$style.ripples"></div> + <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div> <div :class="$style.content"> <slot></slot> </div> @@ -26,9 +26,7 @@ </template> <script lang="ts" setup> -import { nextTick, onMounted, useCssModule } from 'vue'; - -const $style = useCssModule(); +import { nextTick, onMounted } from 'vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -44,6 +42,7 @@ const props = defineProps<{ full?: boolean; small?: boolean; large?: boolean; + transparent?: boolean; asLike?: boolean; }>(); @@ -80,7 +79,7 @@ function onMousedown(evt: MouseEvent): void { const rect = target.getBoundingClientRect(); const ripple = document.createElement('div'); - ripple.classList.add($style.ripple); + ripple.classList.add(ripples!.dataset.childrenClass!); ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; @@ -194,6 +193,10 @@ function onMousedown(evt: MouseEvent): void { } } + &.transparent { + background: transparent; + } + &.gradate { font-weight: bold; color: var(--fgOnAccent) !important; diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 9e275d6172..7b7bef4787 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -1,20 +1,20 @@ <template> <button - class="hdcaacmi _button" - :class="{ wait, active: isFollowing, full }" + class="_button" + :class="[$style.root, { [$style.wait]: wait, [$style.active]: isFollowing, [$style.full]: full }]" :disabled="wait" @click="onClick" > <template v-if="!wait"> <template v-if="isFollowing"> - <span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i> + <span v-if="full" :class="$style.text">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i> </template> <template v-else> - <span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i> + <span v-if="full" :class="$style.text">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i> </template> </template> <template v-else> - <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true"/> + <span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true"/> </template> </button> </template> @@ -57,8 +57,8 @@ async function onClick() { } </script> -<style lang="scss" scoped> -.hdcaacmi { +<style lang="scss" module> +.root { position: relative; display: inline-block; font-weight: bold; @@ -103,7 +103,7 @@ async function onClick() { } &.active { - color: #fff; + color: var(--fgOnAccent); background: var(--accent); &:hover { @@ -121,9 +121,9 @@ async function onClick() { cursor: wait !important; opacity: 0.7; } +} - > span { - margin-right: 6px; - } +.text { + margin-right: 6px; } </style> diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 408eab7399..4050520eb9 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -26,6 +26,3 @@ const props = withDefaults(defineProps<{ extractor: (item) => item, }); </script> - -<style lang="scss" scoped> -</style> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 06d5b9949a..00ff98774b 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -1,8 +1,8 @@ <template> -<div class="cbbedffa"> +<div :class="$style.root"> <canvas ref="chartEl"></canvas> <MkChartLegend ref="legendEl" style="margin-top: 8px;"/> - <div v-if="fetching" class="fetching"> + <div v-if="fetching" :class="$style.fetching"> <MkLoading/> </div> </div> @@ -817,22 +817,22 @@ onMounted(() => { /* eslint-enable id-denylist */ </script> -<style lang="scss" scoped> -.cbbedffa { +<style lang="scss" module> +.root { position: relative; +} - > .fetching { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - -webkit-backdrop-filter: var(--blur, blur(12px)); - backdrop-filter: var(--blur, blur(12px)); - display: flex; - justify-content: center; - align-items: center; - cursor: wait; - } +.fetching { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(12px)); + backdrop-filter: var(--blur, blur(12px)); + display: flex; + justify-content: center; + align-items: center; + cursor: wait; } </style> diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue index 7cfe535edd..fe5b78754d 100644 --- a/packages/frontend/src/components/MkChartTooltip.vue +++ b/packages/frontend/src/components/MkChartTooltip.vue @@ -1,5 +1,5 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :maxWidth="340" :direction="'top'" :innerMargin="16" @closed="emit('closed')"> <div v-if="title || series"> <div v-if="title" :class="$style.title">{{ title }}</div> <template v-if="series"> diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index da6439fd2c..a6ab5aded4 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -3,7 +3,7 @@ <div v-if="game.ready" :class="$style.game"> <div :class="$style.cps" class="">{{ number(cps) }}cps</div> <div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div> - <button v-click-anime class="_button" :class="$style.button" @click="onClick"> + <button v-click-anime class="_button" @click="onClick"> <img src="/client-assets/cookie.png" :class="$style.img"> </button> </div> @@ -84,10 +84,6 @@ onUnmounted(() => { margin-bottom: 6px; } -.button { - -} - .img { max-width: 90px; } diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index d03331a6eb..af1c57b349 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,12 +1,12 @@ <template> -<div ref="rootEl" class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.hideHeader]: !showHeader, [$style.scrollable]: scrollable, [$style.closed]: !showBody }]"> +<div ref="rootEl" class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.scrollable]: scrollable }]"> <header v-if="showHeader" ref="headerEl" :class="$style.header"> <div :class="$style.title"> <span :class="$style.titleIcon"><slot name="icon"></slot></span> <slot name="header"></slot> </div> <div :class="$style.headerSub"> - <slot name="func" :button-style-class="$style.headerButton"></slot> + <slot name="func" :buttonStyleClass="$style.headerButton"></slot> <button v-if="foldable" :class="$style.headerButton" class="_button" @click="() => showBody = !showBody"> <template v-if="showBody"><i class="ti ti-chevron-up"></i></template> <template v-else><i class="ti ti-chevron-down"></i></template> @@ -14,14 +14,14 @@ </div> </header> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" - @after-enter="afterEnter" + @afterEnter="afterEnter" @leave="leave" - @after-leave="afterLeave" + @afterLeave="afterLeave" > <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]"> <slot></slot> @@ -34,7 +34,7 @@ </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; @@ -83,13 +83,19 @@ function afterLeave(el) { const calcOmit = () => { if (omitted.value || ignoreOmit.value || props.maxHeight == null) return; + if (!contentEl.value) return; const height = contentEl.value.offsetHeight; omitted.value = height > props.maxHeight; }; +const omitObserver = new ResizeObserver((entries, observer) => { + calcOmit(); +}); + onMounted(() => { watch(showBody, v => { - const headerHeight = props.showHeader ? headerEl.value.offsetHeight : 0; + if (!rootEl.value) return; + const headerHeight = props.showHeader ? headerEl.value?.offsetHeight ?? 0 : 0; rootEl.value.style.minHeight = `${headerHeight}px`; if (v) { rootEl.value.style.flexBasis = 'auto'; @@ -100,13 +106,15 @@ onMounted(() => { immediate: true, }); - rootEl.value.style.setProperty('--maxHeight', props.maxHeight + 'px'); + if (rootEl.value) rootEl.value.style.setProperty('--maxHeight', props.maxHeight + 'px'); calcOmit(); - new ResizeObserver((entries, observer) => { - calcOmit(); - }).observe(contentEl.value); + if (contentEl.value) omitObserver.observe(contentEl.value); +}); + +onUnmounted(() => { + omitObserver.disconnect(); }); </script> diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index b81c806b0c..fb11834f4d 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -1,10 +1,10 @@ <template> <Transition appear - :enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" > <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 043a614e46..82363499b7 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -4,7 +4,7 @@ :width="800" :height="500" :scroll="false" - :with-ok-button="true" + :withOkButton="true" @close="cancel()" @ok="ok()" @closed="$emit('closed')" diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index d6303f9675..6942a0e6c3 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -36,7 +36,7 @@ export default defineComponent({ }, setup(props, { slots, expose }) { - const $style = useCssModule(); + const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫 function getDateText(time: string) { const date = new Date(time).getDate(); const month = new Date(time).getMonth() + 1; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 9f5404ce15..4d5df0bba4 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -1,10 +1,18 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')"> +<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')"> <div :class="$style.root"> <div v-if="icon" :class="$style.icon"> <i :class="icon"></i> </div> - <div v-else-if="!input && !select" :class="[$style.icon, $style['type_' + type]]"> + <div + v-else-if="!input && !select" + :class="[$style.icon, { + [$style.type_success]: type === 'success', + [$style.type_error]: type === 'error', + [$style.type_warning]: type === 'warning', + [$style.type_info]: type === 'info', + }]" + > <i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i> <i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i> <i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i> diff --git a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts new file mode 100644 index 0000000000..344f6de47c --- /dev/null +++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import MkDigitalClock from './MkDigitalClock.vue'; +export const Default = { + render(args) { + return { + components: { + MkDigitalClock, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkDigitalClock v-bind="props" />', + }; + }, + args: { + now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkDigitalClock>; diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue index 278dc8a5e7..aea20f2489 100644 --- a/packages/frontend/src/components/MkDigitalClock.vue +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -11,19 +11,21 @@ </template> <script lang="ts" setup> -import { onUnmounted, ref, watch } from 'vue'; +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; const props = withDefaults(defineProps<{ showS?: boolean; showMs?: boolean; offset?: number; + now?: () => Date; }>(), { showS: true, showMs: false, offset: 0 - new Date().getTimezoneOffset(), + now: () => new Date(), }); -let intervalId; const hh = ref(''); const mm = ref(''); const ss = ref(''); @@ -39,9 +41,9 @@ watch(showColon, (v) => { } }); -const tick = () => { - const now = new Date(); - now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); +const tick = (): void => { + const now = props.now(); + now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset); hh.value = now.getHours().toString().padStart(2, '0'); mm.value = now.getMinutes().toString().padStart(2, '0'); ss.value = now.getSeconds().toString().padStart(2, '0'); @@ -52,13 +54,12 @@ const tick = () => { tick(); -watch(() => props.showMs, () => { - if (intervalId) window.clearInterval(intervalId); - intervalId = window.setInterval(tick, props.showMs ? 10 : 1000); -}, { immediate: true }); +onMounted(() => { + defaultIdlingRenderScheduler.add(tick); +}); onUnmounted(() => { - window.clearInterval(intervalId); + defaultIdlingRenderScheduler.delete(tick); }); </script> diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index ab408b5008..f0641161be 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -1,7 +1,6 @@ <template> <div - class="ncvczrfv" - :class="{ isSelected }" + :class="[$style.root, { [$style.isSelected]: isSelected }]" draggable="true" :title="title" @click="onClick" @@ -9,25 +8,27 @@ @dragstart="onDragstart" @dragend="onDragend" > - <div v-if="$i?.avatarId == file.id" class="label"> - <img src="/client-assets/label.svg"/> - <p>{{ i18n.ts.avatar }}</p> - </div> - <div v-if="$i?.bannerId == file.id" class="label"> - <img src="/client-assets/label.svg"/> - <p>{{ i18n.ts.banner }}</p> - </div> - <div v-if="file.isSensitive" class="label red"> - <img src="/client-assets/label-red.svg"/> - <p>{{ i18n.ts.nsfw }}</p> - </div> + <div style="pointer-events: none;"> + <div v-if="$i?.avatarId == file.id" :class="[$style.label]"> + <img :class="$style.labelImg" src="/client-assets/label.svg"/> + <p :class="$style.labelText">{{ i18n.ts.avatar }}</p> + </div> + <div v-if="$i?.bannerId == file.id" :class="[$style.label]"> + <img :class="$style.labelImg" src="/client-assets/label.svg"/> + <p :class="$style.labelText">{{ i18n.ts.banner }}</p> + </div> + <div v-if="file.isSensitive" :class="[$style.label, $style.red]"> + <img :class="$style.labelImg" src="/client-assets/label-red.svg"/> + <p :class="$style.labelText">{{ i18n.ts.nsfw }}</p> + </div> - <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/> - <p class="name"> - <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> - <span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> - </p> + <p :class="$style.name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> + </div> </div> </template> @@ -88,20 +89,13 @@ function onDragend() { } </script> -<style lang="scss" scoped> -.ncvczrfv { +<style lang="scss" module> +.root { position: relative; padding: 8px 0 0 0; min-height: 180px; border-radius: 8px; - - &, * { - cursor: pointer; - } - - > * { - pointer-events: none; - } + cursor: pointer; &:hover { background: rgba(#000, 0.05); @@ -165,82 +159,78 @@ function onDragend() { color: #fff; } } +} + +.label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; - > .label { + &:before, + &:after { + content: ""; + display: block; position: absolute; + z-index: 1; + background: #0c7ac9; + } + + &:before { top: 0; + left: 57px; + width: 28px; + height: 8px; + } + + &:after { + top: 57px; left: 0; - pointer-events: none; + width: 8px; + height: 28px; + } + &.red { &:before, &:after { - content: ""; - display: block; - position: absolute; - z-index: 1; - background: #0c7ac9; - } - - &:before { - top: 0; - left: 57px; - width: 28px; - height: 8px; - } - - &:after { - top: 57px; - left: 0; - width: 8px; - height: 28px; - } - - &.red { - &:before, - &:after { - background: #c12113; - } - } - - > img { - position: absolute; - z-index: 2; - top: 0; - left: 0; - } - - > p { - position: absolute; - z-index: 3; - top: 19px; - left: -28px; - width: 120px; - margin: 0; - text-align: center; - line-height: 28px; - color: #fff; - transform: rotate(-45deg); + background: #c12113; } } +} - > .thumbnail { - width: 110px; - height: 110px; - margin: auto; - } +.labelImg { + position: absolute; + z-index: 2; + top: 0; + left: 0; +} - > .name { - display: block; - margin: 4px 0 0 0; - font-size: 0.8em; - text-align: center; - word-break: break-all; - color: var(--fg); - overflow: hidden; +.labelText { + position: absolute; + z-index: 3; + top: 19px; + left: -28px; + width: 120px; + margin: 0; + text-align: center; + line-height: 28px; + color: #fff; + transform: rotate(-45deg); +} - > .ext { - opacity: 0.5; - } - } +.thumbnail { + width: 110px; + height: 110px; + margin: auto; +} + +.name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; } </style> diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 156013b9aa..1969342402 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -1,7 +1,6 @@ <template> <div - class="rghtznwe" - :class="{ draghover }" + :class="[$style.root, { [$style.draghover]: draghover }]" draggable="true" :title="title" @click="onClick" @@ -15,15 +14,15 @@ @dragstart="onDragstart" @dragend="onDragend" > - <p class="name"> - <template v-if="hover"><i class="ti ti-folder ti-fw"></i></template> - <template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template> + <p :class="$style.name"> + <template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> + <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> {{ folder.name }} </p> - <p v-if="defaultStore.state.uploadFolder == folder.id" class="upload"> + <p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} </p> - <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> + <button v-if="selectMode" class="_button" :class="[$style.checkbox, { [$style.checked]: isSelected }]" @click.prevent.stop="checkboxClicked"></button> </div> </template> @@ -267,35 +266,14 @@ function onContextmenu(ev: MouseEvent) { } </script> -<style lang="scss" scoped> -.rghtznwe { +<style lang="scss" module> +.root { position: relative; padding: 8px; height: 64px; background: var(--driveFolderBg); border-radius: 4px; - - &, * { - cursor: pointer; - } - - *:not(.checkbox) { - pointer-events: none; - } - - > .checkbox { - position: absolute; - bottom: 8px; - right: 8px; - width: 16px; - height: 16px; - background: #fff; - border: solid 1px #000; - - &.checked { - background: var(--accent); - } - } + cursor: pointer; &.draghover { &:after { @@ -310,24 +288,38 @@ function onContextmenu(ev: MouseEvent) { border-radius: 4px; } } +} - > .name { - margin: 0; - font-size: 0.9em; - color: var(--desktopDriveFolderFg); +.checkbox { + position: absolute; + bottom: 8px; + right: 8px; + width: 16px; + height: 16px; + background: #fff; + border: solid 1px #000; - > i { - margin-right: 4px; - margin-left: 2px; - text-align: left; - } + &.checked { + background: var(--accent); } +} - > .upload { - margin: 4px 4px; - font-size: 0.8em; - text-align: right; - color: var(--desktopDriveFolderFg); - } +.name { + margin: 0; + font-size: 0.9em; + color: var(--desktopDriveFolderFg); +} + +.icon { + margin-right: 4px; + margin-left: 2px; + text-align: left; +} + +.upload { + margin: 4px 4px; + font-size: 0.8em; + text-align: right; + color: var(--desktopDriveFolderFg); } </style> diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index dbbfef5f05..3349603d3b 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -1,13 +1,13 @@ <template> -<div class="drylbebk" - :class="{ draghover }" +<div + :class="[$style.root, { [$style.draghover]: draghover }]" @click="onClick" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.stop="onDrop" > - <i v-if="folder == null" class="ti ti-cloud"></i> + <i v-if="folder == null" class="ti ti-cloud" style="margin-right: 4px;"></i> <span>{{ folder == null ? i18n.ts.drive : folder.name }}</span> </div> </template> @@ -130,18 +130,10 @@ function onDrop(ev: DragEvent) { } </script> -<style lang="scss" scoped> -.drylbebk { - > * { - pointer-events: none; - } - +<style lang="scss" module> +.root { &.draghover { background: #eee; } - - > i { - margin-right: 4px; - } } </style> diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index bfec57d6a0..52aef450d9 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -1,89 +1,90 @@ <template> -<div class="yfudmmck"> - <nav> - <div class="path" @contextmenu.prevent.stop="() => {}"> +<div :class="$style.root"> + <nav :class="$style.nav"> + <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}"> <XNavFolder - :class="{ current: folder == null }" - :parent-folder="folder" + :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]" + :parentFolder="folder" @move="move" @upload="upload" - @remove-file="removeFile" - @remove-folder="removeFolder" + @removeFile="removeFile" + @removeFolder="removeFolder" /> <template v-for="f in hierarchyFolders"> - <span class="separator"><i class="ti ti-chevron-right"></i></span> + <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> <XNavFolder :folder="f" - :parent-folder="folder" + :parentFolder="folder" + :class="[$style.navPathItem]" @move="move" @upload="upload" - @remove-file="removeFile" - @remove-folder="removeFolder" + @removeFile="removeFile" + @removeFolder="removeFolder" /> </template> - <span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span> - <span v-if="folder != null" class="folder current">{{ folder.name }}</span> + <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> + <span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span> </div> - <button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button> + <button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button> </nav> <div - ref="main" class="main" - :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.prevent.stop="onDrop" @contextmenu.stop="onContextmenu" > - <div ref="contents" class="contents"> - <div v-show="folders.length > 0" ref="foldersContainer" class="folders"> + <div ref="contents"> + <div v-show="folders.length > 0" ref="foldersContainer" :class="$style.folders"> <XFolder v-for="(f, i) in folders" :key="f.id" v-anim="i" - class="folder" + :class="$style.folder" :folder="f" - :select-mode="select === 'folder'" - :is-selected="selectedFolders.some(x => x.id === f.id)" + :selectMode="select === 'folder'" + :isSelected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" @move="move" @upload="upload" - @remove-file="removeFile" - @remove-folder="removeFolder" + @removeFile="removeFile" + @removeFolder="removeFolder" @dragstart="isDragSource = true" @dragend="isDragSource = false" /> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div v-for="(n, i) in 16" :key="i" class="padding"></div> + <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div> <MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton> </div> - <div v-show="files.length > 0" ref="filesContainer" class="files"> + <div v-show="files.length > 0" ref="filesContainer" :class="$style.files"> <XFile v-for="(file, i) in files" :key="file.id" v-anim="i" - class="file" + :class="$style.file" :file="file" - :select-mode="select === 'file'" - :is-selected="selectedFiles.some(x => x.id === file.id)" + :selectMode="select === 'file'" + :isSelected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" @dragstart="isDragSource = true" @dragend="isDragSource = false" /> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div v-for="(n, i) in 16" :key="i" class="padding"></div> + <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div> <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton> </div> - <div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty"> - <p v-if="draghover">{{ i18n.t('empty-draghover') }}</p> - <p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p> - <p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p> + <div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty"> + <div v-if="draghover">{{ i18n.t('empty-draghover') }}</div> + <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</div> + <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div> </div> </div> <MkLoading v-if="fetching"/> </div> - <div v-if="draghover" class="dropzone"></div> - <input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> + <div v-if="draghover" :class="$style.dropzone"></div> + <input ref="fileInput" style="display: none;" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> </div> </template> @@ -95,7 +96,7 @@ import XNavFolder from '@/components/MkDrive.navFolder.vue'; import XFolder from '@/components/MkDrive.folder.vue'; import XFile from '@/components/MkDrive.file.vue'; import * as os from '@/os'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { uploadFile, uploads } from '@/scripts/upload'; @@ -131,7 +132,7 @@ const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const uploadings = uploads; -const connection = stream.useChannel('drive'); +const connection = useStream().useChannel('drive'); const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい // ドロップされようとしているか @@ -658,147 +659,116 @@ onBeforeUnmount(() => { }); </script> -<style lang="scss" scoped> -.yfudmmck { +<style lang="scss" module> +.root { display: flex; flex-direction: column; height: 100%; +} - > nav { - display: flex; - z-index: 2; - width: 100%; - padding: 0 8px; - box-sizing: border-box; - overflow: auto; - font-size: 0.9em; - box-shadow: 0 1px 0 var(--divider); - - &, * { - user-select: none; - } - - > .path { - display: inline-block; - vertical-align: bottom; - line-height: 42px; - white-space: nowrap; - - > * { - display: inline-block; - margin: 0; - padding: 0 8px; - line-height: 42px; - cursor: pointer; - - * { - pointer-events: none; - } - - &:hover { - text-decoration: underline; - } - - &.current { - font-weight: bold; - cursor: default; - - &:hover { - text-decoration: none; - } - } +.nav { + display: flex; + z-index: 2; + width: 100%; + padding: 0 8px; + box-sizing: border-box; + overflow: auto; + font-size: 0.9em; + box-shadow: 0 1px 0 var(--divider); + user-select: none; +} - &.separator { - margin: 0; - padding: 0; - opacity: 0.5; - cursor: default; +.navPath { + display: inline-block; + vertical-align: bottom; + line-height: 42px; + white-space: nowrap; +} - > i { - margin: 0; - } - } - } - } +.navPathItem { + display: inline-block; + margin: 0; + padding: 0 8px; + line-height: 42px; + cursor: pointer; - > .menu { - margin-left: auto; - padding: 0 12px; - } + &:hover { + text-decoration: underline; } - > .main { - flex: 1; - overflow: auto; - padding: var(--margin); + &.navCurrent { + font-weight: bold; + cursor: default; - &, * { - user-select: none; + &:hover { + text-decoration: none; } + } - &.fetching { - cursor: wait !important; - - * { - pointer-events: none; - } - - > .contents { - opacity: 0.5; - } - } + &.navSeparator { + margin: 0; + padding: 0; + opacity: 0.5; + cursor: default; + } +} - &.uploading { - height: calc(100% - 38px - 100px); - } +.navMenu { + margin-left: auto; + padding: 0 12px; +} - > .contents { +.main { + flex: 1; + overflow: auto; + padding: var(--margin); + user-select: none; - > .folders, - > .files { - display: flex; - flex-wrap: wrap; + &.fetching { + cursor: wait !important; + opacity: 0.5; + pointer-events: none; + } - > .folder, - > .file { - flex-grow: 1; - width: 128px; - margin: 4px; - box-sizing: border-box; - } + &.uploading { + height: calc(100% - 38px - 100px); + } +} - > .padding { - flex-grow: 1; - pointer-events: none; - width: 128px + 8px; - } - } +.folders, +.files { + display: flex; + flex-wrap: wrap; +} - > .empty { - padding: 16px; - text-align: center; - pointer-events: none; - opacity: 0.5; +.folder, +.file { + flex-grow: 1; + width: 128px; + margin: 4px; + box-sizing: border-box; +} - > p { - margin: 0; - } - } - } - } +.padding { + flex-grow: 1; + pointer-events: none; + width: 128px + 8px; +} - > .dropzone { - position: absolute; - left: 0; - top: 38px; - width: 100%; - height: calc(100% - 38px); - border: dashed 2px var(--focus); - pointer-events: none; - } +.empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; +} - > input { - display: none; - } +.dropzone { + position: absolute; + left: 0; + top: 38px; + width: 100%; + height: calc(100% - 38px); + border: dashed 2px var(--focus); + pointer-events: none; } </style> diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 33379ed5ca..490aed6e04 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -1,16 +1,16 @@ <template> -<div ref="thumbnail" class="zdjebgpv"> +<div ref="thumbnail" :class="$style.root"> <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> - <i v-else-if="is === 'image'" class="ti ti-photo icon"></i> - <i v-else-if="is === 'video'" class="ti ti-video icon"></i> - <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i> - <i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i> - <i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i> - <i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i> - <i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i> - <i v-else class="ti ti-file icon"></i> + <i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i> + <i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i> + <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i> + <i v-else-if="is === 'csv'" class="ti ti-file-text" :class="$style.icon"></i> + <i v-else-if="is === 'pdf'" class="ti ti-file-text" :class="$style.icon"></i> + <i v-else-if="is === 'textfile'" class="ti ti-file-text" :class="$style.icon"></i> + <i v-else-if="is === 'archive'" class="ti ti-file-zip" :class="$style.icon"></i> + <i v-else class="ti ti-file" :class="$style.icon"></i> - <i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i> + <i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i> </div> </template> @@ -53,28 +53,28 @@ const isThumbnailAvailable = computed(() => { }); </script> -<style lang="scss" scoped> -.zdjebgpv { +<style lang="scss" module> +.root { position: relative; display: flex; background: var(--panel); border-radius: 8px; overflow: clip; +} - > .icon-sub { - position: absolute; - width: 30%; - height: auto; - margin: 0; - right: 4%; - bottom: 4%; - } +.iconSub { + position: absolute; + width: 30%; + height: auto; + margin: 0; + right: 4%; + bottom: 4%; +} - > .icon { - pointer-events: none; - margin: auto; - font-size: 32px; - color: #777; - } +.icon { + pointer-events: none; + margin: auto; + font-size: 32px; + color: #777; } </style> diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index 8d2b19c013..da873cb90b 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -3,8 +3,8 @@ ref="dialog" :width="800" :height="500" - :with-ok-button="true" - :ok-button-disabled="(type === 'file') && (selected.length === 0)" + :withOkButton="true" + :okButtonDisabled="(type === 'file') && (selected.length === 0)" @click="cancel()" @close="cancel()" @ok="ok()" @@ -14,7 +14,7 @@ {{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }} <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> </template> - <XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/> + <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/> </MkModalWindow> </template> diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue index 8b2abc15a3..64ccbec9c3 100644 --- a/packages/frontend/src/components/MkDriveWindow.vue +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -1,15 +1,15 @@ <template> <MkWindow ref="window" - :initial-width="800" - :initial-height="500" - :can-resize="true" + :initialWidth="800" + :initialHeight="500" + :canResize="true" @closed="emit('closed')" > <template #header> {{ i18n.ts.drive }} </template> - <XDrive :initial-folder="initialFolder"/> + <XDrive :initialFolder="initialFolder"/> </MkWindow> </template> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 9eaf16374b..cf856fd31f 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -1,7 +1,8 @@ <template> <div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> <input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter"> - <div ref="emojisEl" class="emojis"> + <!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 --> + <div ref="emojisEl" class="emojis" tabindex="-1"> <section class="result"> <div v-if="searchResultCustom.length > 0" class="body"> <button @@ -69,8 +70,8 @@ <XSection v-for="category in customEmojiCategories" :key="`custom:${category}`" - :initial-shown="false" - :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).map(e => `:${e.name}:`))" + :initialShown="false" + :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))" @chosen="chosen" > {{ category || i18n.ts.other }} @@ -101,7 +102,8 @@ import { isTouchUsing } from '@/scripts/touch'; import { deviceKind } from '@/scripts/device-kind'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; -import { customEmojiCategories, customEmojis } from '@/custom-emojis'; +import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis'; +import { $i } from '@/account'; const props = withDefaults(defineProps<{ showPinned?: boolean; @@ -222,7 +224,6 @@ watch(q, () => { if (newQ.includes(' ')) { // AND検索 const keywords = newQ.split(' '); - // 名前にキーワードが含まれている for (const emoji of emojis) { if (keywords.every(keyword => emoji.name.includes(keyword))) { matches.add(emoji); @@ -231,11 +232,12 @@ watch(q, () => { } if (matches.size >= max) return matches; - // 名前またはエイリアスにキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { - matches.add(emoji); - if (matches.size >= max) break; + for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const emoji of emojis) { + if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } } } } else { @@ -247,13 +249,14 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const emoji of emojis) { - if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) { - matches.add(emoji); - if (matches.size >= max) break; + for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const emoji of emojis) { + if (index[emoji.char].some(k => k.startsWith(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } } } - if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.name.includes(newQ)) { @@ -263,10 +266,12 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const emoji of emojis) { - if (emoji.keywords.some(keyword => keyword.includes(newQ))) { - matches.add(emoji); - if (matches.size >= max) break; + for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const emoji of emojis) { + if (index[emoji.char].some(k => k.includes(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } } } } @@ -274,10 +279,14 @@ watch(q, () => { return matches; }; - searchResultCustom.value = Array.from(searchCustom()); + searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable); searchResultUnicode.value = Array.from(searchUnicode()); }); +function filterAvailable(emoji: Misskey.entities.CustomEmoji): boolean { + return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); +} + function focus() { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { searchEl.value?.focus({ @@ -347,7 +356,7 @@ function done(query?: string): boolean | void { if (query == null || typeof query !== 'string') return; const q2 = query.replace(/:/g, ''); - const exactMatchCustom = customEmojis.value.find(emoji => emoji.name === q2); + const exactMatchCustom = customEmojisMap.get(q2); if (exactMatchCustom) { chosen(exactMatchCustom); return true; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index c568d4ed5c..cfb65e3b63 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -2,10 +2,10 @@ <MkModal ref="modal" v-slot="{ type, maxHeight }" - :z-priority="'middle'" - :prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" - :transparent-bg="true" - :manual-showing="manualShowing" + :zPriority="'middle'" + :preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" + :transparentBg="true" + :manualShowing="manualShowing" :src="src" @click="modal?.close()" @opening="opening" @@ -14,11 +14,11 @@ > <MkEmojiPicker ref="picker" - class="ryghynhb _popup _shadow" - :class="{ drawer: type === 'drawer' }" - :show-pinned="showPinned" - :as-reaction-picker="asReactionPicker" - :as-drawer="type === 'drawer'" + class="_popup _shadow" + :class="{ [$style.drawer]: type === 'drawer' }" + :showPinned="showPinned" + :asReactionPicker="asReactionPicker" + :asDrawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen" /> @@ -67,12 +67,10 @@ function opening() { } </script> -<style lang="scss" scoped> -.ryghynhb { - &.drawer { - border-radius: 24px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - } +<style lang="scss" module> +.drawer { + border-radius: 24px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; } </style> diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue index 84970410e9..9fecfd6082 100644 --- a/packages/frontend/src/components/MkEmojiPickerWindow.vue +++ b/packages/frontend/src/components/MkEmojiPickerWindow.vue @@ -1,13 +1,14 @@ <template> -<MkWindow ref="window" - :initial-width="300" - :initial-height="290" - :can-resize="true" +<MkWindow + ref="window" + :initialWidth="300" + :initialHeight="290" + :canResize="true" :mini="true" :front="true" @closed="emit('closed')" > - <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" as-window :class="$style.picker" @chosen="chosen"/> + <MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/> </MkWindow> </template> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index 95eef45df0..61b87bda78 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -3,14 +3,14 @@ ref="dialog" :width="400" :height="450" - :with-ok-button="true" - :ok-button-disabled="false" + :withOkButton="true" + :okButtonDisabled="false" @ok="ok()" @close="dialog.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.describeFile }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/> <MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription"> <template #label>{{ i18n.ts.caption }}</template> diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 475e01c8d4..5dd07fc7da 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -1,9 +1,9 @@ <template> -<div class="ssazuxis"> - <header class="_button" :style="{ background: bg }" @click="showBody = !showBody"> - <div class="title"><div><slot name="header"></slot></div></div> - <div class="divider"></div> - <button class="_button"> +<div ref="el" :class="$style.root"> + <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody"> + <div :class="$style.title"><div><slot name="header"></slot></div></div> + <div :class="$style.divider"></div> + <button class="_button" :class="$style.button"> <template v-if="showBody"><i class="ti ti-chevron-up"></i></template> <template v-else><i class="ti ti-chevron-down"></i></template> </button> @@ -11,9 +11,9 @@ <Transition :name="defaultStore.state.animation ? 'folder-toggle' : ''" @enter="enter" - @after-enter="afterEnter" + @afterEnter="afterEnter" @leave="leave" - @after-leave="afterLeave" + @afterLeave="afterLeave" > <div v-show="showBody"> <slot></slot> @@ -22,84 +22,71 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onMounted, ref, shallowRef, watch } from 'vue'; import tinycolor from 'tinycolor2'; import { miLocalStorage } from '@/local-storage'; import { defaultStore } from '@/store'; const miLocalStoragePrefix = 'ui:folder:' as const; -export default defineComponent({ - props: { - expanded: { - type: Boolean, - required: false, - default: true, - }, - persistKey: { - type: String, - required: false, - default: null, - }, - }, - data() { - return { - defaultStore, - bg: null, - showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded, - }; - }, - watch: { - showBody() { - if (this.persistKey) { - miLocalStorage.setItem(`${miLocalStoragePrefix}${this.persistKey}`, this.showBody ? 't' : 'f'); - } - }, - }, - mounted() { - function getParentBg(el: Element | null): string { - if (el == null || el.tagName === 'BODY') return 'var(--bg)'; - const bg = el.style.background || el.style.backgroundColor; - if (bg) { - return bg; - } else { - return getParentBg(el.parentElement); - } - } - const rawBg = getParentBg(this.$el); - const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - bg.setAlpha(0.85); - this.bg = bg.toRgbString(); - }, - methods: { - toggleContent(show: boolean) { - this.showBody = show; - }, +const props = withDefaults(defineProps<{ + expanded?: boolean; + persistKey?: string; +}>(), { + expanded: true, +}); + +const el = shallowRef<HTMLDivElement>(); +const bg = ref<string | null>(null); +const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); + +watch(showBody, () => { + if (props.persistKey) { + miLocalStorage.setItem(`${miLocalStoragePrefix}${props.persistKey}`, showBody.value ? 't' : 'f'); + } +}); + +function enter(el: Element) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; +} + +function afterEnter(el: Element) { + el.style.height = null; +} + +function leave(el: Element) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; +} + +function afterLeave(el: Element) { + el.style.height = null; +} - enter(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; - el.offsetHeight; // reflow - el.style.height = elementHeight + 'px'; - }, - afterEnter(el) { - el.style.height = null; - }, - leave(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; - el.offsetHeight; // reflow - el.style.height = 0; - }, - afterLeave(el) { - el.style.height = null; - }, - }, +onMounted(() => { + function getParentBg(el: HTMLElement | null): string { + if (el == null || el.tagName === 'BODY') return 'var(--bg)'; + const bg = el.style.background || el.style.backgroundColor; + if (bg) { + return bg; + } else { + return getParentBg(el.parentElement); + } + } + const rawBg = getParentBg(el.value); + const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + _bg.setAlpha(0.85); + bg.value = _bg.toRgbString(); }); </script> -<style lang="scss" scoped> +<style lang="scss" module> .folder-toggle-enter-active, .folder-toggle-leave-active { overflow-y: clip; transition: opacity 0.5s, height 0.5s !important; @@ -111,45 +98,41 @@ export default defineComponent({ opacity: 0; } -.ssazuxis { +.root { position: relative; +} - > header { - display: flex; - position: relative; - z-index: 10; - position: sticky; - top: var(--stickyTop, 0px); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(20px)); +.header { + display: flex; + position: relative; + z-index: 10; + position: sticky; + top: var(--stickyTop, 0px); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(20px)); +} - > .title { - display: grid; - place-content: center; - margin: 0; - padding: 12px 16px 12px 0; - } +.title { + display: grid; + place-content: center; + margin: 0; + padding: 12px 16px 12px 0; +} - > .divider { - flex: 1; - margin: auto; - height: 1px; - background: var(--divider); - } +.divider { + flex: 1; + margin: auto; + height: 1px; + background: var(--divider); +} - > button { - padding: 12px 0 12px 16px; - } - } +.button { + padding: 12px 0 12px 16px; } @container (max-width: 500px) { - .ssazuxis { - > header { - > .title { - padding: 8px 10px 8px 0; - } - } + .title { + padding: 8px 10px 8px 0; } } </style> diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 10eee6aab1..70f0cc5cda 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -5,8 +5,8 @@ <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> <div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerText"> - <div :class="$style.headerTextMain"> - <slot name="label"></slot> + <div> + <MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine> </div> <div :class="$style.headerTextSub"> <slot name="caption"></slot> @@ -22,18 +22,18 @@ <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened"> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" - @after-enter="afterEnter" + @afterEnter="afterEnter" @leave="leave" - @after-leave="afterLeave" + @afterLeave="afterLeave" > <KeepAlive> <div v-show="opened"> - <MkSpacer :margin-min="14" :margin-max="22"> + <MkSpacer :marginMin="14" :marginMax="22"> <slot></slot> </MkSpacer> </div> @@ -185,10 +185,6 @@ onMounted(() => { padding-right: 12px; } -.headerTextMain { - -} - .headerTextSub { color: var(--fgTransparentWeak); font-size: .85em; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index beee21c647..b732fbb2b9 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -1,30 +1,30 @@ <template> <button - class="kpoogebi _button" - :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }" + class="_button" + :class="[$style.root, { [$style.wait]: wait, [$style.active]: isFollowing || hasPendingFollowRequestFromYou, [$style.full]: full, [$style.large]: large }]" :disabled="wait" @click="onClick" > <template v-if="!wait"> <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> - <span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i> + <span v-if="full" :class="$style.text">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i> </template> <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 --> - <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/> + <span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/> </template> <template v-else-if="isFollowing"> - <span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i> + <span v-if="full" :class="$style.text">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i> </template> <template v-else-if="!isFollowing && user.isLocked"> - <span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i> + <span v-if="full" :class="$style.text">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i> </template> <template v-else-if="!isFollowing && !user.isLocked"> - <span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i> + <span v-if="full" :class="$style.text">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i> </template> </template> <template v-else> - <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/> + <span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/> </template> </button> </template> @@ -33,7 +33,7 @@ import { onBeforeUnmount, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; import { claimAchievement } from '@/scripts/achievements'; import { $i } from '@/account'; @@ -50,7 +50,7 @@ const props = withDefaults(defineProps<{ let isFollowing = $ref(props.user.isFollowing); let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); let wait = $ref(false); -const connection = stream.useChannel('main'); +const connection = useStream().useChannel('main'); if (props.user.isFollowing == null) { os.api('users/show', { @@ -126,13 +126,12 @@ onBeforeUnmount(() => { }); </script> -<style lang="scss" scoped> -.kpoogebi { +<style lang="scss" module> +.root { position: relative; display: inline-block; font-weight: bold; - color: var(--accent); - background: transparent; + color: var(--fgOnWhite); border: solid 1px var(--accent); padding: 0; height: 31px; @@ -196,9 +195,9 @@ onBeforeUnmount(() => { cursor: wait !important; opacity: 0.7; } +} - > span { - margin-right: 6px; - } +.text { + margin-right: 6px; } </style> diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 0befa7e3ae..1264c42331 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -8,27 +8,28 @@ > <template #header>{{ i18n.ts.forgotPassword }}</template> - <form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> - <div class="main _gaps_m"> - <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> - <template #label>{{ i18n.ts.username }}</template> - <template #prefix>@</template> - </MkInput> + <MkSpacer :marginMin="20" :marginMax="28"> + <form v-if="instance.enableEmail" @submit.prevent="onSubmit"> + <div class="_gaps_m"> + <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> + <template #label>{{ i18n.ts.username }}</template> + <template #prefix>@</template> + </MkInput> - <MkInput v-model="email" type="email" :spellcheck="false" required> - <template #label>{{ i18n.ts.emailAddress }}</template> - <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template> - </MkInput> + <MkInput v-model="email" type="email" :spellcheck="false" required> + <template #label>{{ i18n.ts.emailAddress }}</template> + <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template> + </MkInput> - <MkButton type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton> - </div> - <div class="sub"> - <MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA> + <MkButton type="submit" rounded :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton> + + <MkInfo>{{ i18n.ts._forgotPassword.ifNoEmail }}</MkInfo> + </div> + </form> + <div v-else> + {{ i18n.ts._forgotPassword.contactAdmin }} </div> - </form> - <div v-else class="bafecedb"> - {{ i18n.ts._forgotPassword.contactAdmin }} - </div> + </MkSpacer> </MkModalWindow> </template> @@ -37,6 +38,7 @@ import { } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import { instance } from '@/instance'; import { i18n } from '@/i18n'; @@ -62,20 +64,3 @@ async function onSubmit() { dialog.close(); } </script> - -<style lang="scss" scoped> -.bafeceda { - > .main { - padding: 24px; - } - - > .sub { - border-top: solid 0.5px var(--divider); - padding: 24px; - } -} - -.bafecedb { - padding: 24px; -} -</style> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 979df2e7c1..6d2b391e6d 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -2,9 +2,9 @@ <MkModalWindow ref="dialog" :width="450" - :can-close="false" - :with-ok-button="true" - :ok-button-disabled="false" + :canClose="false" + :withOkButton="true" + :okButtonDisabled="false" @click="cancel()" @ok="ok()" @close="cancel()" @@ -14,7 +14,7 @@ {{ title }} </template> - <MkSpacer :margin-min="20" :margin-max="32"> + <MkSpacer :marginMin="20" :marginMax="32"> <div class="_gaps_m"> <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> <MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> @@ -41,7 +41,7 @@ <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> </MkRadios> - <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter"> + <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkRange> @@ -54,8 +54,8 @@ </MkModalWindow> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { reactive, shallowRef } from 'vue'; import MkInput from './MkInput.vue'; import MkTextarea from './MkTextarea.vue'; import MkSwitch from './MkSwitch.vue'; @@ -66,58 +66,36 @@ import MkRadios from './MkRadios.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkModalWindow, - MkInput, - MkTextarea, - MkSwitch, - MkSelect, - MkRange, - MkButton, - MkRadios, - }, +const props = defineProps<{ + title: string; + form: any; +}>(); - props: { - title: { - type: String, - required: true, - }, - form: { - type: Object, - required: true, - }, - }, +const emit = defineEmits<{ + (ev: 'done', v: { + canceled?: boolean; + result?: any; + }): void; +}>(); - emits: ['done'], +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const values = reactive({}); - data() { - return { - values: {}, - i18n, - }; - }, +for (const item in props.form) { + values[item] = props.form[item].default ?? null; +} - created() { - for (const item in this.form) { - this.values[item] = this.form[item].default ?? null; - } - }, +function ok() { + emit('done', { + result: values, + }); + dialog.value.close(); +} - methods: { - ok() { - this.$emit('done', { - result: this.values, - }); - this.$refs.dialog.close(); - }, - - cancel() { - this.$emit('done', { - canceled: true, - }); - this.$refs.dialog.close(); - }, - }, -}); +function cancel() { + emit('done', { + canceled: true, + }); + dialog.value.close(); +} </script> diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index 57b3e75513..72ac0a58f9 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -44,6 +44,10 @@ export const Default = { ], parameters: { layout: 'centered', + chromatic: { + // FIXME: flaky + disableSnapshot: true, + }, }, } satisfies StoryObj<typeof MkGalleryPostPreview>; export const Hover = { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 4f8f7b945a..3a39ad963b 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -5,16 +5,13 @@ <ImgWithBlurhash class="img layered" :transition="safe ? null : { - enterActiveClass: $style.transition_toggle_enterActive, + duration: 500, leaveActiveClass: $style.transition_toggle_leaveActive, - enterFromClass: $style.transition_toggle_enterFrom, leaveToClass: $style.transition_toggle_leaveTo, - enterToClass: $style.transition_toggle_enterTo, - leaveFromClass: $style.transition_toggle_leaveFrom, }" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash" - :force-blurhash="!show" + :forceBlurhash="!show" /> </Transition> </div> @@ -53,24 +50,16 @@ function leaveHover(): void { </script> <style lang="scss" module> -.transition_toggle_enterActive, .transition_toggle_leaveActive { - transition: opacity 0.5s; + transition: opacity .5s; position: absolute; top: 0; left: 0; } -.transition_toggle_enterFrom, .transition_toggle_leaveTo { opacity: 0; } - -.transition_toggle_enterTo, -.transition_toggle_leaveFrom { - transition: none; - opacity: 1; -} </style> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkImageViewer.vue b/packages/frontend/src/components/MkImageViewer.vue deleted file mode 100644 index a90e27e502..0000000000 --- a/packages/frontend/src/components/MkImageViewer.vue +++ /dev/null @@ -1,78 +0,0 @@ -<template> -<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')"> - <div class="xubzgfga"> - <header>{{ image.name }}</header> - <img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/> - <footer> - <span>{{ image.type }}</span> - <span>{{ bytes(image.size) }}</span> - <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> - </footer> - </div> -</MkModal> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import * as misskey from 'misskey-js'; -import bytes from '@/filters/bytes'; -import number from '@/filters/number'; -import MkModal from '@/components/MkModal.vue'; - -const props = withDefaults(defineProps<{ - image: misskey.entities.DriveFile; -}>(), { -}); - -const emit = defineEmits<{ - (ev: 'closed'): void; -}>(); - -const modal = $shallowRef<InstanceType<typeof MkModal>>(); -</script> - -<style lang="scss" scoped> -.xubzgfga { - margin: auto; - display: flex; - flex-direction: column; - height: 100%; - - > header, - > footer { - align-self: center; - display: inline-block; - padding: 6px 9px; - font-size: 90%; - background: rgba(0, 0, 0, 0.5); - border-radius: 6px; - color: #fff; - } - - > header { - margin-bottom: 8px; - opacity: 0.9; - } - - > img { - display: block; - flex: 1; - min-height: 0; - object-fit: contain; - width: 100%; - cursor: zoom-out; - image-orientation: from-image; - } - - > footer { - margin-top: 8px; - opacity: 0.8; - - > span + span { - margin-left: 0.5em; - padding-left: 0.5em; - border-left: solid 1px rgba(255, 255, 255, 0.5); - } - } -} -</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 6406a35060..672a28f6d0 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -1,30 +1,60 @@ <template> -<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''"> - <img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/> - <Transition - mode="in-out" - :enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined" - :leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined" - :enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined" - :leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined" - :enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined" - :leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined" +<div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''"> + <TransitionGroup + :duration="defaultStore.state.animation && props.transition?.duration || undefined" + :enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined" + :leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" + :enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined" + :leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined" + :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined" + :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined" > - <canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/> - <img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/> - </Transition> + <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/> + <img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/> + </TransitionGroup> </div> </template> +<script lang="ts"> +import { $ref } from 'vue/macros'; +import DrawBlurhash from '@/workers/draw-blurhash?worker'; +import TestWebGL2 from '@/workers/test-webgl2?worker'; +import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; + +const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => { + // テスト環境で Web Worker インスタンスは作成できない + if (import.meta.env.MODE === 'test') { + resolve(null); + return; + } + const testWorker = new TestWebGL2(); + testWorker.addEventListener('message', event => { + if (event.data.result) { + const workers = new WorkerMultiDispatch( + () => new DrawBlurhash(), + Math.min(navigator.hardwareConcurrency - 1, 4), + ); + resolve(workers); + if (_DEV_) console.log('WebGL2 in worker is supported!'); + } else { + resolve(null); + if (_DEV_) console.log('WebGL2 in worker is not supported...'); + } + testWorker.terminate(); + }); +}); +</script> + <script lang="ts" setup> -import { onMounted, shallowRef, useCssModule, watch } from 'vue'; -import { decode } from 'blurhash'; +import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { render } from 'buraha'; import { defaultStore } from '@/store'; -const $style = useCssModule(); - const props = withDefaults(defineProps<{ transition?: { + duration?: number | { enter: number; leave: number; }; enterActiveClass?: string; leaveActiveClass?: string; enterFromClass?: string; @@ -51,67 +81,141 @@ const props = withDefaults(defineProps<{ forceBlurhash: false, }); +const viewId = uuid(); const canvas = shallowRef<HTMLCanvasElement>(); +const root = shallowRef<HTMLDivElement>(); +const img = shallowRef<HTMLImageElement>(); let loaded = $ref(false); -let width = $ref(props.width); -let height = $ref(props.height); +let canvasWidth = $ref(64); +let canvasHeight = $ref(64); +let imgWidth = $ref(props.width); +let imgHeight = $ref(props.height); +let bitmapTmp = $ref<CanvasImageSource | undefined>(); +const hide = computed(() => !loaded || props.forceBlurhash); -function onLoad() { - loaded = true; +function waitForDecode() { + if (props.src != null && props.src !== '') { + nextTick() + .then(() => img.value?.decode()) + .then(() => { + loaded = true; + }, error => { + console.error('Error occured during decoding image', img.value, error); + throw Error(error); + }); + } else { + loaded = false; + } } -watch([() => props.width, () => props.height], () => { +watch([() => props.width, () => props.height, root], () => { const ratio = props.width / props.height; if (ratio > 1) { - width = Math.round(64 * ratio); - height = 64; + canvasWidth = Math.round(64 * ratio); + canvasHeight = 64; } else { - width = 64; - height = Math.round(64 / ratio); + canvasWidth = 64; + canvasHeight = Math.round(64 / ratio); } + + const clientWidth = root.value?.clientWidth ?? 300; + imgWidth = clientWidth; + imgHeight = Math.round(clientWidth / ratio); }, { immediate: true, }); -function draw() { - if (props.hash == null || !canvas.value) return; - const pixels = decode(props.hash, width, height); +function drawImage(bitmap: CanvasImageSource) { + // canvasがない(mountedされていない)場合はTmpに保存しておく + if (!canvas.value) { + bitmapTmp = bitmap; + return; + } + + // canvasがあれば描画する + bitmapTmp = undefined; + const ctx = canvas.value.getContext('2d'); + if (!ctx) return; + ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight); +} + +async function draw() { + if (!canvas.value || props.hash == null) return; + const ctx = canvas.value.getContext('2d'); - const imageData = ctx!.createImageData(width, height); - imageData.data.set(pixels); - ctx!.putImageData(imageData, 0, 0); + if (!ctx) return; + + // avgColorでお茶をにごす + ctx.beginPath(); + ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + const workers = await workerPromise; + if (workers) { + workers.postMessage( + { + id: viewId, + hash: props.hash, + width: canvasWidth, + height: canvasHeight, + }, + undefined, + ); + } else { + try { + const work = document.createElement('canvas'); + work.width = canvasWidth; + work.height = canvasHeight; + render(props.hash, work); + ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight); + } catch (error) { + console.error('Error occured during drawing blurhash', error); + } + } } -watch([() => props.hash, canvas], () => { +function workerOnMessage(event: MessageEvent) { + if (event.data.id !== viewId) return; + drawImage(event.data.bitmap as ImageBitmap); +} + +workerPromise.then(worker => { + if (worker) { + worker.addListener(workerOnMessage); + } + draw(); }); -onMounted(() => { +watch(() => props.src, () => { + waitForDecode(); +}); + +watch(() => props.hash, () => { draw(); }); -</script> -<style lang="scss" module> -.transition_toggle_enterActive, -.transition_toggle_leaveActive { - position: absolute; - top: 0; - left: 0; -} +onMounted(() => { + // drawImageがmountedより先に呼ばれている場合はここで描画する + if (bitmapTmp) { + drawImage(bitmapTmp); + } + waitForDecode(); +}); -.transition_toggle_enterTo, -.transition_toggle_leaveFrom { - opacity: 0; -} +onUnmounted(() => { + workerPromise.then(worker => { + worker?.removeListener(workerOnMessage); + }); +}); +</script> -.loader { +<style lang="scss" module> +.transition_leaveActive { position: absolute; top: 0; left: 0; - width: 0; - height: 0; } - .root { position: relative; width: 100%; diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index ff69c79641..4b6a775635 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -1,9 +1,9 @@ <template> -<div class="alqyeyti" :class="{ oneline }"> - <div class="key"> +<div :class="[$style.root, { [$style.oneline]: oneline }]"> + <div :class="$style.key"> <slot name="key"></slot> </div> - <div class="value"> + <div :class="$style.value"> <slot name="value"></slot> <button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button> </div> @@ -30,24 +30,18 @@ const copy_ = () => { }; </script> -<style lang="scss" scoped> -.alqyeyti { - > .key { - font-size: 0.85em; - padding: 0 0 0.25em 0; - opacity: 0.75; - } - +<style lang="scss" module> +.root { &.oneline { display: flex; - > .key { + .key { width: 30%; font-size: 1em; padding: 0 8px 0 0; } - > .value { + .value { width: 70%; white-space: nowrap; overflow: hidden; @@ -55,4 +49,10 @@ const copy_ = () => { } } } + +.key { + font-size: 0.85em; + padding: 0 0 0.25em 0; + opacity: 0.75; +} </style> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 80e5cc8270..9262778612 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -1,5 +1,5 @@ <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items"> diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 5ca4c50518..5902d6fd25 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -1,27 +1,27 @@ <template> -<div class="mk-media-banner"> - <div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false"> - <span class="icon"><i class="ti ti-alert-triangle"></i></span> +<div :class="$style.root"> + <div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false"> + <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> - <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio"> - <VuePlyr :options="{ volume: 0.5 }"> - <audio controls preload="metadata"> - <source - :src="media.url" - :type="media.type" - /> - </audio> - </VuePlyr> + <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :class="$style.audio"> + <audio + ref="audioEl" + :src="media.url" + :title="media.name" + controls + preload="metadata" + @volumechange="volumechange" + /> </div> <a - v-else class="download" + v-else :class="$style.download" :href="media.url" :title="media.name" :download="media.name" > - <span class="icon"><i class="ti ti-download"></i></span> + <span style="font-size: 1.6em;"><i class="ti ti-download"></i></span> <b>{{ media.name }}</b> </a> </div> @@ -30,9 +30,7 @@ <script lang="ts" setup> import { onMounted } from 'vue'; import * as misskey from 'misskey-js'; -import VuePlyr from 'vue-plyr'; import { soundConfigStore } from '@/scripts/sound'; -import 'vue-plyr/dist/vue-plyr.css'; import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ @@ -52,55 +50,34 @@ onMounted(() => { }); </script> -<style lang="scss" scoped> -.mk-media-banner { +<style lang="scss" module> +.root { width: 100%; border-radius: 4px; margin-top: 4px; - // overflow: clip; - - --plyr-color-main: var(--accent); - --plyr-audio-controls-background: var(--bg); - --plyr-audio-controls-color: var(--accentLighten); - - > .download, - > .sensitive { - display: flex; - align-items: center; - font-size: 12px; - padding: 8px 12px; - white-space: nowrap; - - > * { - display: block; - } - - > b { - overflow: hidden; - text-overflow: ellipsis; - } - - > *:not(:last-child) { - margin-right: .2em; - } + overflow: clip; +} - > .icon { - font-size: 1.6em; - } - } +.download, +.sensitive { + display: flex; + align-items: center; + font-size: 12px; + padding: 8px 12px; + white-space: nowrap; +} - > .download { - background: var(--noteAttachedFile); - } +.download { + background: var(--noteAttachedFile); +} - > .sensitive { - background: #111; - color: #fff; - } +.sensitive { + background: #111; + color: #fff; +} - > .audio { - border-radius: 8px; - // overflow: clip; - } +.audio { + border-radius: 8px; + overflow: clip; } </style> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 42dc9e79ff..b29871c363 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -1,29 +1,39 @@ <template> -<div v-if="hide" :class="$style.hidden" @click="hide = false"> - <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/> - <div :class="$style.hiddenText"> - <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b> - <span style="display: block;">{{ i18n.ts.clickToShow }}</span> - </div> - </div> -</div> -<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'"> +<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick"> <a :class="$style.imageContainer" :href="image.url" :title="image.name" > - <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/> + <ImgWithBlurhash + :hash="image.blurhash" + :src="(defaultStore.state.enableDataSaverMode && hide) ? null : url" + :forceBlurhash="hide" + :cover="hide" + :alt="image.comment || image.name" + :title="image.comment || image.name" + :width="image.properties.width" + :height="image.properties.height" + :style="hide ? 'filter: brightness(0.5);' : null" + /> </a> - <div :class="$style.indicators"> - <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> - <div v-if="image.comment" :class="$style.indicator">ALT</div> - <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div> - </div> - <button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button> - <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button> + <template v-if="hide"> + <div :class="$style.hiddenText"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </div> + </template> + <template v-else> + <div :class="$style.indicators"> + <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> + <div v-if="image.comment" :class="$style.indicator">ALT</div> + <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div> + </div> + <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button> + </template> </div> </template> @@ -53,6 +63,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages) : props.image.thumbnailUrl, ); +function onclick() { + if (hide) { + hide = false; + } +} + // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); @@ -62,10 +78,17 @@ watch(() => props.image, () => { }); function showMenu(ev: MouseEvent) { - os.popupMenu([...(iAmModerator ? [{ - text: i18n.ts.markAsSensitive, + os.popupMenu([{ + text: i18n.ts.hide, icon: 'ti ti-eye-off', action: () => { + hide = true; + }, + }, ...(iAmModerator ? [{ + text: i18n.ts.markAsSensitive, + icon: 'ti ti-eye-exclamation', + danger: true, + action: () => { os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true }); }, }] : [])], ev.currentTarget ?? ev.target); @@ -105,34 +128,20 @@ function showMenu(ev: MouseEvent) { background-size: 16px 16px; } -.hide { - display: block; - position: absolute; - border-radius: 6px; - background-color: var(--accentedBg); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - color: var(--accent); - font-size: 0.8em; - padding: 6px 8px; - text-align: center; - top: 12px; - right: 12px; -} - .menu { display: block; position: absolute; - border-radius: 6px; + border-radius: 999px; background-color: rgba(0, 0, 0, 0.3); -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); color: #fff; font-size: 0.8em; - padding: 6px 8px; + width: 32px; + height: 32px; text-align: center; - bottom: 12px; - right: 12px; + bottom: 10px; + right: 10px; } .imageContainer { @@ -149,12 +158,10 @@ function showMenu(ev: MouseEvent) { .indicators { display: inline-flex; position: absolute; - top: 12px; - left: 12px; - text-align: center; + top: 10px; + left: 10px; pointer-events: none; opacity: .5; - font-size: 14px; gap: 6px; } @@ -165,7 +172,7 @@ function showMenu(ev: MouseEvent) { color: var(--accentLighten); display: inline-block; font-weight: bold; - font-size: 12px; - padding: 2px 6px; + font-size: 0.8em; + padding: 2px 5px; } </style> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index e456ff3eec..a0a2450054 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -6,7 +6,11 @@ ref="gallery" :class="[ $style.medias, - count <= 4 ? $style['n' + count] : $style.nMany, + count === 1 ? [$style.n1, { + [$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9', + [$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1', + [$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3', + }] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany, ]" > <template v-for="media in mediaList.filter(media => previewable(media))"> @@ -19,7 +23,7 @@ </template> <script lang="ts" setup> -import { onMounted, ref, useCssModule, watch } from 'vue'; +import { onMounted, watch, shallowRef } from 'vue'; import * as misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; @@ -36,13 +40,42 @@ const props = defineProps<{ raw?: boolean; }>(); -const $style = useCssModule(); - -const gallery = ref<HTMLDivElement>(); +const gallery = shallowRef<HTMLDivElement>(); const pswpZIndex = os.claimZIndex('middle'); document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = $computed(() => props.mediaList.filter(media => previewable(media)).length); +function calcAspectRatio() { + if (!gallery.value) return; + + let img = props.mediaList[0]; + + if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) { + gallery.value.style.aspectRatio = ''; + return; + } + + // アスペクト比上限設定では、横長の場合は高さを縮小させる + const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + + switch (defaultStore.state.mediaListWithOneImageAppearance) { + case '16_9': + gallery.value.style.aspectRatio = ratioMax(16 / 9); + break; + case '1_1': + gallery.value.style.aspectRatio = ratioMax(1); + break; + case '2_3': + gallery.value.style.aspectRatio = ratioMax(2 / 3); + break; + default: + gallery.value.style.aspectRatio = ''; + break; + } +} + +watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio()); + onMounted(() => { const lightbox = new PhotoSwipeLightbox({ dataSource: props.mediaList @@ -64,7 +97,7 @@ onMounted(() => { return item; }), gallery: gallery.value, - mainClass: $style.pswp, + mainClass: 'pswp', children: '.image', thumbSelector: '.image', loop: false, @@ -162,12 +195,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { display: grid; grid-gap: 8px; - // for webkit height: 100%; + width: 100%; &.n1 { - aspect-ratio: 16/9; grid-template-rows: 1fr; + + // default (expand) + min-height: 64px; + max-height: clamp( + 64px, + 50cqh, + min(360px, 50vh) + ); + + &.n116_9 { + min-height: none; + max-height: none; + aspect-ratio: 16 / 9; // fallback + } + + &.n11_1{ + min-height: none; + max-height: none; + aspect-ratio: 1 / 1; // fallback + } + + &.n12_3 { + min-height: none; + max-height: none; + aspect-ratio: 2 / 3; // fallback + } } &.n2 { @@ -211,7 +269,7 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { border-radius: 8px; } -.pswp { +:global(.pswp) { --pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important; --pswp-bg: var(--modalBg) !important; } diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index a4b76300e6..40bae90b5e 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,26 +1,28 @@ <template> -<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false"> +<div v-if="hide" :class="$style.hidden" @click="hide = false"> <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること --> - <div> - <b v-if="video.isSensitive"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else><i class="ti ti-movie"></i> {{ defaultStore.state.enableDataSaverMode && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <div :class="$style.sensitive"> + <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.enableDataSaverMode && video.size ? bytes(video.size) : i18n.ts.video }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> </div> -<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu"> - <VuePlyr :options="{ volume: 0.5 }"> - <video - controls - :data-poster="video.thumbnailUrl" +<div v-else :class="$style.visible"> + <video + :class="$style.video" + :poster="video.thumbnailUrl" + :title="video.comment" + :alt="video.comment" + preload="none" + controls + @contextmenu.stop + > + <source + :src="video.url" + :type="video.type" > - <source - size="720" - :src="video.url" - :type="video.type" - /> - </video> - </VuePlyr> - <i class="ti ti-eye-off" @click="hide = true"></i> + </video> + <i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i> </div> </template> @@ -28,9 +30,7 @@ import { ref } from 'vue'; import * as misskey from 'misskey-js'; import bytes from '@/filters/bytes'; -import VuePlyr from 'vue-plyr'; import { defaultStore } from '@/store'; -import 'vue-plyr/dist/vue-plyr.css'; import { i18n } from '@/i18n'; const props = defineProps<{ @@ -40,56 +40,49 @@ const props = defineProps<{ const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); </script> -<style lang="scss" scoped> -.kkjnbbplepmiyuadieoenjgutgcmtsvu { +<style lang="scss" module> +.visible { position: relative; +} - --plyr-color-main: var(--accent); - - > i { - display: block; - position: absolute; - border-radius: 6px; - background-color: var(--fg); - color: var(--accentLighten); - font-size: 14px; - opacity: .5; - padding: 3px 6px; - text-align: center; - cursor: pointer; - top: 12px; - right: 12px; - } - - > video { - display: flex; - justify-content: center; - align-items: center; +.hide { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 14px; + opacity: .5; + padding: 3px 6px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; +} - font-size: 3.5em; - overflow: hidden; - background-position: center; - background-size: cover; - width: 100%; - height: 100%; - } +.video { + display: flex; + justify-content: center; + align-items: center; + font-size: 3.5em; + overflow: hidden; + background-position: center; + background-size: cover; + width: 100%; + height: 100%; } -.icozogqfvdetwohsdglrbswgrejoxbdj { +.hidden { display: flex; justify-content: center; align-items: center; background: #111; color: #fff; +} - > div { - display: table-cell; - text-align: center; - font-size: 12px; - - > b { - display: block; - } - } +.sensitive { + display: table-cell; + text-align: center; + font-size: 12px; } </style> diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index 481c3710ca..bb256c394b 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -2,7 +2,7 @@ <MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }"> <img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt=""> <span> - <span :class="$style.username">@{{ username }}</span> + <span>@{{ username }}</span> <span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span> </span> </MkA> diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index e0935efbe7..4fedfe7014 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -1,6 +1,6 @@ <template> <div ref="el" :class="$style.root"> - <MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> + <MkMenu :items="items" :align="align" :width="width" :asDrawer="false" @close="onChildClosed"/> </div> </template> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index e513a65a32..7dd6a8c88f 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -49,8 +49,8 @@ <span>{{ i18n.ts.none }}</span> </span> </div> - <div v-if="childMenu" :class="$style.child"> - <XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/> + <div v-if="childMenu"> + <XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned"/> </div> </div> </template> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 99df9e8150..bb5c6c7aab 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -1,11 +1,31 @@ <template> <Transition :name="transitionName" - :enter-active-class="$style['transition_' + transitionName + '_enterActive']" - :leave-active-class="$style['transition_' + transitionName + '_leaveActive']" - :enter-from-class="$style['transition_' + transitionName + '_enterFrom']" - :leave-to-class="$style['transition_' + transitionName + '_leaveTo']" - :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened" + :enterActiveClass="normalizeClass({ + [$style.transition_modalDrawer_enterActive]: transitionName === 'modal-drawer', + [$style.transition_modalPopup_enterActive]: transitionName === 'modal-popup', + [$style.transition_modal_enterActive]: transitionName === 'modal', + [$style.transition_send_enterActive]: transitionName === 'send', + })" + :leaveActiveClass="normalizeClass({ + [$style.transition_modalDrawer_leaveActive]: transitionName === 'modal-drawer', + [$style.transition_modalPopup_leaveActive]: transitionName === 'modal-popup', + [$style.transition_modal_leaveActive]: transitionName === 'modal', + [$style.transition_send_leaveActive]: transitionName === 'send', + })" + :enterFromClass="normalizeClass({ + [$style.transition_modalDrawer_enterFrom]: transitionName === 'modal-drawer', + [$style.transition_modalPopup_enterFrom]: transitionName === 'modal-popup', + [$style.transition_modal_enterFrom]: transitionName === 'modal', + [$style.transition_send_enterFrom]: transitionName === 'send', + })" + :leaveToClass="normalizeClass({ + [$style.transition_modalDrawer_leaveTo]: transitionName === 'modal-drawer', + [$style.transition_modalPopup_leaveTo]: transitionName === 'modal-popup', + [$style.transition_modal_leaveTo]: transitionName === 'modal', + [$style.transition_send_leaveTo]: transitionName === 'send', + })" + :duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened" > <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> @@ -17,7 +37,7 @@ </template> <script lang="ts" setup> -import { nextTick, onMounted, watch, provide } from 'vue'; +import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch } from 'vue'; import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { defaultStore } from '@/store'; @@ -38,7 +58,7 @@ type ModalTypes = 'popup' | 'dialog' | 'drawer'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; anchor?: { x: string; y: string; }; - src?: HTMLElement; + src?: HTMLElement | null; preferType?: ModalTypes | 'auto'; zPriority?: 'low' | 'middle' | 'high'; noOverlap?: boolean; @@ -264,6 +284,10 @@ const onOpened = () => { }, { passive: true }); }; +const alignObserver = new ResizeObserver((entries, observer) => { + align(); +}); + onMounted(() => { watch(() => props.src, async () => { if (props.src) { @@ -278,12 +302,14 @@ onMounted(() => { }, { immediate: true }); nextTick(() => { - new ResizeObserver((entries, observer) => { - align(); - }).observe(content!); + alignObserver.observe(content!); }); }); +onUnmounted(() => { + alignObserver.disconnect(); +}); + defineExpose({ close, }); @@ -339,8 +365,8 @@ defineExpose({ } } -.transition_modal-popup_enterActive, -.transition_modal-popup_leaveActive { +.transition_modalPopup_enterActive, +.transition_modalPopup_leaveActive { > .bg { transition: opacity 0.1s !important; } @@ -350,8 +376,8 @@ defineExpose({ transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1), transform 0.1s cubic-bezier(0, 0, 0.2, 1) !important; } } -.transition_modal-popup_enterFrom, -.transition_modal-popup_leaveTo { +.transition_modalPopup_enterFrom, +.transition_modalPopup_leaveTo { > .bg { opacity: 0; } @@ -364,7 +390,7 @@ defineExpose({ } } -.transition_modal-drawer_enterActive { +.transition_modalDrawer_enterActive { > .bg { transition: opacity 0.2s !important; } @@ -373,7 +399,7 @@ defineExpose({ transition: transform 0.2s cubic-bezier(0,.5,0,1) !important; } } -.transition_modal-drawer_leaveActive { +.transition_modalDrawer_leaveActive { > .bg { transition: opacity 0.2s !important; } @@ -382,8 +408,8 @@ defineExpose({ transition: transform 0.2s cubic-bezier(0,.5,0,1) !important; } } -.transition_modal-drawer_enterFrom, -.transition_modal-drawer_leaveTo { +.transition_modalDrawer_enterFrom, +.transition_modalDrawer_leaveTo { > .bg { opacity: 0; } diff --git a/packages/frontend/src/components/MkModalPageWindow.vue b/packages/frontend/src/components/MkModalPageWindow.vue deleted file mode 100644 index b38865f525..0000000000 --- a/packages/frontend/src/components/MkModalPageWindow.vue +++ /dev/null @@ -1,182 +0,0 @@ -<template> -<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> - <div ref="rootEl" class="hrmcaedk" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> - <div class="header" @contextmenu="onContextmenu"> - <button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button> - <span v-else style="display: inline-block; width: 20px"></span> - <span v-if="pageMetadata?.value" class="title"> - <i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i> - <span>{{ pageMetadata?.value.title }}</span> - </span> - <button class="_button" @click="$refs.modal.close()"><i class="ti ti-x"></i></button> - </div> - <div class="body" style="container-type: inline-size;"> - <MkStickyContainer> - <template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template> - <RouterView :router="router"/> - </MkStickyContainer> - </div> - </div> -</MkModal> -</template> - -<script lang="ts" setup> -import { ComputedRef, provide } from 'vue'; -import MkModal from '@/components/MkModal.vue'; -import { popout as _popout } from '@/scripts/popout'; -import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { url } from '@/config'; -import * as os from '@/os'; -import { mainRouter, routes } from '@/router'; -import { i18n } from '@/i18n'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; -import { Router } from '@/nirax'; - -const props = defineProps<{ - initialPath: string; -}>(); - -defineEmits<{ - (ev: 'closed'): void; - (ev: 'click'): void; -}>(); - -const router = new Router(routes, props.initialPath); - -router.addListener('push', ctx => { - -}); - -let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); -let rootEl = $ref(); -let modal = $shallowRef<InstanceType<typeof MkModal>>(); -let path = $ref(props.initialPath); -let width = $ref(860); -let height = $ref(660); -const history = []; - -provide('router', router); -provideMetadataReceiver((info) => { - pageMetadata = info; -}); -provide('shouldOmitHeaderTitle', true); -provide('shouldHeaderThin', true); - -const pageUrl = $computed(() => url + path); -const contextmenu = $computed(() => { - return [{ - type: 'label', - text: path, - }, { - icon: 'ti ti-player-eject', - text: i18n.ts.showInPage, - action: expand, - }, { - icon: 'ti ti-window-maximize', - text: i18n.ts.popout, - action: popout, - }, null, { - icon: 'ti ti-external-link', - text: i18n.ts.openInNewTab, - action: () => { - window.open(pageUrl, '_blank'); - modal.close(); - }, - }, { - icon: 'ti ti-link', - text: i18n.ts.copyLink, - action: () => { - copyToClipboard(pageUrl); - }, - }]; -}); - -function navigate(path, record = true) { - if (record) history.push(router.getCurrentPath()); - router.push(path); -} - -function back() { - navigate(history.pop(), false); -} - -function expand() { - mainRouter.push(path); - modal.close(); -} - -function popout() { - _popout(path, rootEl); - modal.close(); -} - -function onContextmenu(ev: MouseEvent) { - os.contextMenu(contextmenu, ev); -} -</script> - -<style lang="scss" scoped> -.hrmcaedk { - margin: auto; - overflow: hidden; - display: flex; - flex-direction: column; - contain: content; - border-radius: var(--radius); - - --root-margin: 24px; - - @media (max-width: 500px) { - --root-margin: 16px; - } - - > .header { - $height: 52px; - $height-narrow: 42px; - display: flex; - flex-shrink: 0; - height: $height; - line-height: $height; - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: var(--windowHeader); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - - > button { - height: $height; - width: $height; - - &:hover { - color: var(--fgHighlighted); - } - } - - @media (max-width: 500px) { - height: $height-narrow; - line-height: $height-narrow; - padding-left: 16px; - - > button { - height: $height-narrow; - width: $height-narrow; - } - } - - > .title { - flex: 1; - - > .icon { - margin-right: 0.5em; - } - } - } - - > .body { - overflow: auto; - background: var(--bg); - } -} -</style> diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 63c55b904a..08569b4d6e 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -1,5 +1,5 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> +<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')"> <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown"> <div ref="headerEl" :class="$style.header"> <button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index d95f8de311..7c9ddadbf8 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -44,8 +44,8 @@ <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/> <div :class="$style.main"> - <MkNoteHeader :class="$style.header" :note="appearNote" :mini="true"/> - <MkInstanceTicker v-if="showTicker" :class="$style.ticker" :instance="appearNote.user.instance"/> + <MkNoteHeader :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/> @@ -55,17 +55,17 @@ <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else :class="$style.translated"> + <div v-else> <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> + <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/> </div> </div> </div> - <div v-if="appearNote.files.length > 0" :class="$style.files"> - <MkMediaList :media-list="appearNote.files"/> + <div v-if="appearNote.files.length > 0"> + <MkMediaList :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> @@ -79,7 +79,7 @@ </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </div> - <MkReactionsViewer :note="appearNote" :max-number="16"> + <MkReactionsViewer :note="appearNote" :maxNumber="16"> <template #more> <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions"> {{ i18n.ts.more }} @@ -205,8 +205,11 @@ 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('$[x2')) || (appearNote.text.includes('$[x3')) || (appearNote.text.includes('$[x4')) || + (appearNote.text.includes('$[scale')) || + (appearNote.text.includes('$[position')) || (appearNote.text.split('\n').length > 9) || (appearNote.text.length > 500) || (appearNote.files.length >= 5) || @@ -274,7 +277,7 @@ function renote(viaKeyboard = false) { const y = rect.top + (el.offsetHeight / 2); os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - + os.api('notes/create', { renoteId: appearNote.id, channelId: appearNote.channelId, @@ -305,7 +308,7 @@ function renote(viaKeyboard = false) { const y = rect.top + (el.offsetHeight / 2); os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - + os.api('notes/create', { renoteId: appearNote.id, }).then(() => { @@ -379,6 +382,8 @@ function undoReact(note): void { function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; + // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 + if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 0d6d329d98..a65039277b 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -4,25 +4,25 @@ v-show="!isDeleted" ref="el" v-hotkey="keymap" - class="lxwezrsl" - :tabindex="!isDeleted ? '-1' : null" - :class="{ renote: isRenote }" + :class="$style.root" > - <MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/> - <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> - <div v-if="isRenote" class="renote"> - <MkAvatar class="avatar" :user="note.user" link preview/> - <i class="ti ti-repeat"></i> - <I18n :src="i18n.ts.renotedBy" tag="span"> - <template #user> - <MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)"> - <MkUserName :user="note.user"/> - </MkA> - </template> - </I18n> - <div class="info"> - <button ref="renoteTime" class="_button time" @click="showRenoteMenu()"> - <i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i> + <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> + <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> + <div v-if="isRenote" :class="$style.renote"> + <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> + <i class="ti ti-repeat" style="margin-right: 4px;"></i> + <span :class="$style.renoteText"> + <I18n :src="i18n.ts.renotedBy" tag="span"> + <template #user> + <MkA v-user-preview="note.userId" :class="$style.renoteName" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + </span> + <div :class="$style.renoteInfo"> + <button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()"> + <i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i> <MkTime :time="note.createdAt"/> </button> <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> @@ -33,16 +33,16 @@ <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> </div> - <article class="article" @contextmenu.stop="onContextmenu"> - <header class="header"> - <MkAvatar class="avatar" :user="appearNote.user" indicator link preview/> - <div class="body"> - <div class="top"> - <MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)"> + <article :class="$style.note" @contextmenu.stop="onContextmenu"> + <header :class="$style.noteHeader"> + <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/> + <div :class="$style.noteHeaderBody"> + <div> + <MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)"> <MkUserName :nowrap="false" :user="appearNote.user"/> </MkA> - <span v-if="appearNote.user.isBot" class="is-bot">bot</span> - <div class="info"> + <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span> + <div :class="$style.noteHeaderInfo"> <span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]"> <i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i> <i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i> @@ -51,84 +51,81 @@ <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> </div> - <div class="username"><MkAcct :user="appearNote.user"/></div> - <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div> + <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> </div> </header> - <div class="main"> - <div class="body"> - <p v-if="appearNote.cw != null" class="cw"> - <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i"/> - <MkCwButton v-model="showContent" :note="appearNote"/> - </p> - <div v-show="appearNote.cw == null || showContent" class="content"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> - <MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> - <a v-if="appearNote.renote != null" class="rp">RN:</a> - <div v-if="translating || translation" class="translation"> - <MkLoading v-if="translating" mini/> - <div v-else class="translated"> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> - </div> - </div> + <div :class="$style.noteContent"> + <p v-if="appearNote.cw != null" :class="$style.cw"> + <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/> + <MkCwButton v-model="showContent" :note="appearNote"/> + </p> + <div v-show="appearNote.cw == null || showContent"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/> + <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> + <div v-if="translating || translation" :class="$style.translation"> + <MkLoading v-if="translating" mini/> + <div v-else> + <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/> </div> - <div v-if="appearNote.files.length > 0" class="files"> - <MkMediaList :media-list="appearNote.files"/> - </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/> - <div v-if="appearNote.renote" class="renote"><MkNoteSimple :note="appearNote.renote" class="note"/></div> </div> - <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> - </div> - <footer class="footer"> - <div class="info"> - <MkA class="created-at" :to="notePage(appearNote)"> - <MkTime :time="appearNote.createdAt" mode="detail"/> - </MkA> + <div v-if="appearNote.files.length > 0"> + <MkMediaList :mediaList="appearNote.files"/> </div> - <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> - <button class="button _button" @click="reply()"> - <i class="ti ti-arrow-back-up"></i> - <p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p> - </button> - <button - v-if="canRenote" - ref="renoteButton" - class="button _button" - @mousedown="renote()" - > - <i class="ti ti-repeat"></i> - <p v-if="appearNote.renoteCount > 0" class="count">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="button _button" disabled> - <i class="ti ti-ban"></i> - </button> - <button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> - <i v-else class="ti ti-plus"></i> - </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> - <i class="ti ti-minus"></i> - </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="button _button" @mousedown="clip()"> - <i class="ti ti-paperclip"></i> - </button> - <button ref="menuButton" class="button _button" @mousedown="menu()"> - <i class="ti ti-dots"></i> - </button> - </footer> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + </div> + <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </div> + <footer> + <div :class="$style.noteFooterInfo"> + <MkA :to="notePage(appearNote)"> + <MkTime :time="appearNote.createdAt" mode="detail"/> + </MkA> + </div> + <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <button class="_button" :class="$style.noteFooterButton" @click="reply()"> + <i class="ti ti-arrow-back-up"></i> + <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p> + </button> + <button + v-if="canRenote" + ref="renoteButton" + class="_button" + :class="$style.noteFooterButton" + @mousedown="renote()" + > + <i class="ti ti-repeat"></i> + <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="_button" :class="$style.noteFooterButton" disabled> + <i class="ti ti-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)"> + <i class="ti ti-minus"></i> + </button> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> + <i class="ti ti-paperclip"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <i class="ti ti-dots"></i> + </button> + </footer> </article> - <MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> + <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/> </div> -<div v-else class="_panel muted" @click="muted = false"> +<div v-else class="_panel" :class="$style.muted" @click="muted = false"> <I18n :src="i18n.ts.userSaysSomething" tag="small"> <template #name> - <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> </MkA> </template> @@ -438,318 +435,249 @@ if (appearNote.replyId) { } </script> -<style lang="scss" scoped> -.lxwezrsl { +<style lang="scss" module> +.root { position: relative; transition: box-shadow 0.1s ease; overflow: clip; contain: content; +} - &:focus-visible { - outline: none; - - &:after { - content: ""; - pointer-events: none; - display: block; - position: absolute; - z-index: 10; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - width: calc(100% - 8px); - height: calc(100% - 8px); - border: dashed 1px var(--focus); - border-radius: var(--radius); - box-sizing: border-box; - } - } - - &:hover > .article > .main > .footer > .button { - opacity: 1; - } - - > .reply-to { - opacity: 0.7; - padding-bottom: 0; - } +.replyTo { + opacity: 0.7; + padding-bottom: 0; +} - > .reply-to-more { - opacity: 0.7; - } +.replyToMore { + opacity: 0.7; +} - > .renote { - display: flex; - align-items: center; - padding: 16px 32px 8px 32px; - line-height: 28px; - white-space: pre; - color: var(--renote); +.renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); +} - > .avatar { - flex-shrink: 0; - display: inline-block; - width: 28px; - height: 28px; - margin: 0 8px 0 0; - border-radius: 6px; - } +.renoteAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; +} - > i { - margin-right: 4px; - } +.renoteText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; +} - > span { - overflow: hidden; - flex-shrink: 1; - text-overflow: ellipsis; - white-space: nowrap; +.renoteName { + font-weight: bold; +} - > .name { - font-weight: bold; - } - } +.renoteInfo { + margin-left: auto; + font-size: 0.9em; +} - > .info { - margin-left: auto; - font-size: 0.9em; +.renoteTime { + flex-shrink: 0; + color: inherit; +} - > .time { - flex-shrink: 0; - color: inherit; +.renote + .note { + padding-top: 8px; +} - > .dropdownIcon { - margin-right: 4px; - } - } - } - } +.note { + padding: 32px; + font-size: 1.2em; - > .renote + .article { - padding-top: 8px; + &:hover > .main > .footer > .button { + opacity: 1; } +} - > .article { - padding: 32px; - font-size: 1.2em; - - > .header { - display: flex; - position: relative; - margin-bottom: 16px; - align-items: center; - - > .avatar { - display: block; - flex-shrink: 0; - width: 58px; - height: 58px; - } - - > .body { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - padding-left: 16px; - font-size: 0.95em; - - > .top { - > .name { - font-weight: bold; - line-height: 1.3; - } +.noteHeader { + display: flex; + position: relative; + margin-bottom: 16px; + align-items: center; +} - > .is-bot { - display: inline-block; - margin: 0 0.5em; - padding: 4px 6px; - font-size: 80%; - line-height: 1; - border: solid 0.5px var(--divider); - border-radius: 4px; - } +.noteHeaderAvatar { + display: block; + flex-shrink: 0; + width: 58px; + height: 58px; +} - > .info { - float: right; - } - } +.noteHeaderBody { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 16px; + font-size: 0.95em; +} - > .username { - margin-bottom: 2px; - line-height: 1.3; - word-wrap: anywhere; - } - } - } +.noteHeaderName { + font-weight: bold; + line-height: 1.3; +} - > .main { - > .body { - container-type: inline-size; +.isBot { + display: inline-block; + margin: 0 0.5em; + padding: 4px 6px; + font-size: 80%; + line-height: 1; + border: solid 0.5px var(--divider); + border-radius: 4px; +} - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; +.noteHeaderInfo { + float: right; +} - > .text { - margin-right: 8px; - } - } +.noteHeaderUsername { + margin-bottom: 2px; + line-height: 1.3; + word-wrap: anywhere; +} - > .content { - > .text { - overflow-wrap: break-word; +.noteContent { + container-type: inline-size; + overflow-wrap: break-word; +} - > .reply { - color: var(--accent); - margin-right: 0.5em; - } +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } +.noteReplyTarget { + color: var(--accent); + margin-right: 0.5em; +} - > .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); - padding: 12px; - margin-top: 8px; - } - } +.rn { + margin-left: 4px; + font-style: oblique; + color: var(--renote); +} - > .url-preview { - margin-top: 8px; - } +.translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; +} - > .poll { - font-size: 80%; - } +.poll { + font-size: 80%; +} - > .renote { - padding: 8px 0; +.quote { + padding: 8px 0; +} - > .note { - padding: 16px; - border: dashed 1px var(--renote); - border-radius: 8px; - } - } - } +.quoteNote { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; +} - > .channel { - opacity: 0.7; - font-size: 80%; - } - } +.channel { + opacity: 0.7; + font-size: 80%; +} - > .footer { - > .info { - margin: 16px 0; - opacity: 0.7; - font-size: 0.9em; - } +.noteFooterInfo { + margin: 16px 0; + opacity: 0.7; + font-size: 0.9em; +} - > .button { - margin: 0; - padding: 8px; - opacity: 0.7; +.noteFooterButton { + margin: 0; + padding: 8px; + opacity: 0.7; - &:not(:last-child) { - margin-right: 28px; - } + &:not(:last-child) { + margin-right: 28px; + } - &:hover { - color: var(--fgHighlighted); - } + &:hover { + color: var(--fgHighlighted); + } +} - > .count { - display: inline; - margin: 0 0 0 8px; - opacity: 0.7; - } +.noteFooterButtonCount { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; - &.reacted { - color: var(--accent); - } - } - } - } + &.reacted { + color: var(--accent); } +} - > .reply { - border-top: solid 0.5px var(--divider); - } +.reply { + border-top: solid 0.5px var(--divider); } @container (max-width: 500px) { - .lxwezrsl { + .root { font-size: 0.9em; } } @container (max-width: 450px) { - .lxwezrsl { - > .renote { - padding: 8px 16px 0 16px; - } + .renote { + padding: 8px 16px 0 16px; + } - > .article { - padding: 16px; + .note { + padding: 16px; + } - > .header { - > .avatar { - width: 50px; - height: 50px; - } - } - } + .noteHeaderAvatar { + width: 50px; + height: 50px; } } @container (max-width: 350px) { - .lxwezrsl { - > .article { - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 18px; - } - } - } - } + .noteFooterButton { + &:not(:last-child) { + margin-right: 18px; } } } @container (max-width: 300px) { - .lxwezrsl { + .root { font-size: 0.825em; + } - > .article { - > .header { - > .avatar { - width: 50px; - height: 50px; - } - } + .noteHeaderAvatar { + width: 50px; + height: 50px; + } - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 12px; - } - } - } - } + .noteFooterButton { + &:not(:last-child) { + margin-right: 12px; } } } diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index 6b55c27869..6786f8b256 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -6,7 +6,7 @@ <MkUserName :user="$i" :nowrap="true"/> </div> <div> - <div :class="$style.content"> + <div> <Mfm :text="text.trim()" :author="$i" :i="$i"/> </div> </div> diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index bd27a43b61..21be1454a7 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -5,7 +5,7 @@ <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> <p v-if="note.cw != null" :class="$style.cw"> - <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emoji-urls="note.emojis"/> + <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emojiUrls="note.emojis"/> <MkCwButton v-model="showContent" :note="note"/> </p> <div v-show="note.cw == null || showContent"> diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index a4e949c898..9cc2b7a967 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -15,7 +15,7 @@ :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" - :no-gap="noGap" + :noGap="noGap" :ad="true" :class="$style.notes" > diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index efae687e66..d25332b10f 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -5,7 +5,19 @@ <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/> <img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/> - <div :class="[$style.subIcon, $style['t_' + notification.type]]"> + <div + :class="[$style.subIcon, { + [$style.t_follow]: notification.type === 'follow', + [$style.t_followRequestAccepted]: notification.type === 'followRequestAccepted', + [$style.t_receiveFollowRequest]: notification.type === 'receiveFollowRequest', + [$style.t_renote]: notification.type === 'renote', + [$style.t_reply]: notification.type === 'reply', + [$style.t_mention]: notification.type === 'mention', + [$style.t_quote]: notification.type === 'quote', + [$style.t_pollEnded]: notification.type === 'pollEnded', + [$style.t_achievementEarned]: notification.type === 'achievementEarned', + }]" + > <i v-if="notification.type === 'follow'" class="ti ti-plus"></i> <i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i> <i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i> @@ -20,8 +32,8 @@ v-else-if="notification.type === 'reaction'" ref="reactionRef" :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" - :custom-emojis="notification.note.emojis" - :no-style="true" + :customEmojis="notification.note.emojis" + :noStyle="true" style="width: 100%; height: 100%;" /> </div> @@ -34,7 +46,7 @@ <span v-else>{{ notification.header }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> </header> - <div :class="$style.content"> + <div> <MkA v-if="notification.type === 'reaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> <i class="ti ti-quote" :class="$style.quote"></i> <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> @@ -243,9 +255,6 @@ useTooltip(reactionRef, (showing) => { font-size: 0.9em; } -.content { -} - .text { display: flex; width: 100%; diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue index f6d0e5681d..598d3a0551 100644 --- a/packages/frontend/src/components/MkNotificationSettingWindow.vue +++ b/packages/frontend/src/components/MkNotificationSettingWindow.vue @@ -3,15 +3,15 @@ ref="dialog" :width="400" :height="450" - :with-ok-button="true" - :ok-button-disabled="false" + :withOkButton="true" + :okButtonDisabled="false" @ok="ok()" @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.notificationSetting }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps_m"> <template v-if="showGlobalToggle"> <MkSwitch v-model="useGlobalSetting"> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 1aea95fe0e..70224bffa1 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -8,9 +8,9 @@ </template> <template #default="{ items: notifications }"> - <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true"> + <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> - <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> + <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/> </MkDateSeparatedList> </template> </MkPagination> @@ -22,7 +22,7 @@ import MkPagination, { Paging } from '@/components/MkPagination.vue'; import XNotification from '@/components/MkNotification.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkNote from '@/components/MkNote.vue'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { notificationTypes } from '@/const'; @@ -45,7 +45,7 @@ const pagination: Paging = { const onNotification = (notification) => { const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); if (isMuted || document.visibilityState === 'visible') { - stream.send('readNotification'); + useStream().send('readNotification'); } if (!isMuted) { @@ -56,7 +56,7 @@ const onNotification = (notification) => { let connection; onMounted(() => { - connection = stream.useChannel('main'); + connection = useStream().useChannel('main'); connection.on('notification', onNotification); }); diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index e7fc73bce3..d48e7886eb 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -28,54 +28,38 @@ </div> </template> -<script lang="ts"> -import { defineComponent, reactive } from 'vue'; +<script lang="ts" setup> +import { reactive } from 'vue'; import number from '@/filters/number'; +import XValue from '@/components/MkObjectView.value.vue'; -export default defineComponent({ - name: 'XValue', +const props = defineProps<{ + value: any; +}>(); - props: { - value: { - required: true, - }, - }, +const collapsed = reactive({}); - setup(props) { - const collapsed = reactive({}); - - if (isObject(props.value)) { - for (const key in props.value) { - collapsed[key] = collapsable(props.value[key]); - } - } - - function isObject(v): boolean { - return typeof v === 'object' && !Array.isArray(v) && v !== null; - } +if (isObject(props.value)) { + for (const key in props.value) { + collapsed[key] = collapsable(props.value[key]); + } +} - function isArray(v): boolean { - return Array.isArray(v); - } +function isObject(v): boolean { + return typeof v === 'object' && !Array.isArray(v) && v !== null; +} - function isEmpty(v): boolean { - return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); - } +function isArray(v): boolean { + return Array.isArray(v); +} - function collapsable(v): boolean { - return (isObject(v) || isArray(v)) && !isEmpty(v); - } +function isEmpty(v): boolean { + return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); +} - return { - number, - collapsed, - isObject, - isArray, - isEmpty, - collapsable, - }; - }, -}); +function collapsable(v): boolean { + return (isObject(v) || isArray(v)) && !isEmpty(v); +} </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue index 55578a37f6..8b1ed74142 100644 --- a/packages/frontend/src/components/MkObjectView.vue +++ b/packages/frontend/src/components/MkObjectView.vue @@ -1,5 +1,5 @@ <template> -<div class="zhyxdalp"> +<div> <XValue :value="value" :collapsed="false"/> </div> </template> @@ -12,9 +12,3 @@ const props = defineProps<{ value: Record<string, unknown>; }>(); </script> - -<style lang="scss" scoped> -.zhyxdalp { - -} -</style> diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index e2d68d12c3..668f9ff5af 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -8,7 +8,7 @@ </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, onUnmounted } from 'vue'; import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ @@ -21,16 +21,22 @@ let content = $shallowRef<HTMLElement>(); let omitted = $ref(false); let ignoreOmit = $ref(false); -onMounted(() => { - const calcOmit = () => { - if (omitted || ignoreOmit) return; - omitted = content.offsetHeight > props.maxHeight; - }; +const calcOmit = () => { + if (omitted || ignoreOmit) return; + omitted = content.offsetHeight > props.maxHeight; +}; +const omitObserver = new ResizeObserver((entries, observer) => { calcOmit(); - new ResizeObserver((entries, observer) => { - calcOmit(); - }).observe(content); +}); + +onMounted(() => { + calcOmit(); + omitObserver.observe(content); +}); + +onUnmounted(() => { + omitObserver.disconnect(); }); </script> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 02ce58451d..709b5a52df 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -1,23 +1,23 @@ <template> <MkWindow ref="windowEl" - :initial-width="500" - :initial-height="500" - :can-resize="true" - :close-button="true" - :buttons-left="buttonsLeft" - :buttons-right="buttonsRight" + :initialWidth="500" + :initialHeight="500" + :canResize="true" + :closeButton="true" + :buttonsLeft="buttonsLeft" + :buttonsRight="buttonsRight" :contextmenu="contextmenu" @closed="$emit('closed')" > <template #header> <template v-if="pageMetadata?.value"> - <i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> + <i v-if="pageMetadata.value.icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> <span>{{ pageMetadata.value.title }}</span> </template> </template> - <div :class="$style.root" :style="{ background: pageMetadata?.value?.bg }" style="container-type: inline-size;"> + <div :class="$style.root" style="container-type: inline-size;"> <RouterView :key="reloadCount" :router="router"/> </div> </MkWindow> diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index cd8af560e4..740094b113 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -1,9 +1,9 @@ <template> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" mode="out-in" > <MkLoading v-if="fetching"/> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 0810061ff9..464e340116 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -1,19 +1,19 @@ <template> -<div class="tivcixzd" :class="{ done: closed || isVoted }"> - <ul> - <li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)"> - <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> - <span> - <template v-if="choice.isVoted"><i class="ti ti-check"></i></template> +<div :class="{ [$style.done]: closed || isVoted }"> + <ul :class="$style.choices"> + <li v-for="(choice, i) in note.poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> + <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> + <span :class="$style.fg"> + <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template> <Mfm :text="choice.text" :plain="true"/> - <span v-if="showResult" class="votes">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span> + <span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span> </span> </li> </ul> - <p v-if="!readOnly"> + <p v-if="!readOnly" :class="$style.info"> <span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span> <span> · </span> - <a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> + <a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> <span v-if="isVoted">{{ i18n.ts._poll.voted }}</span> <span v-else-if="closed">{{ i18n.ts._poll.closed }}</span> <span v-if="remaining > 0"> · {{ timer }}</span> @@ -86,67 +86,51 @@ const vote = async (id) => { }; </script> -<style lang="scss" scoped> -.tivcixzd { - > ul { - display: block; - margin: 0; - padding: 0; - list-style: none; - - > li { - display: block; - position: relative; - margin: 4px 0; - padding: 4px; - //border: solid 0.5px var(--divider); - background: var(--accentedBg); - border-radius: 4px; - overflow: clip; - cursor: pointer; - - > .backdrop { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: var(--accent); - background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); - transition: width 1s ease; - } - - > span { - position: relative; - display: inline-block; - padding: 3px 5px; - background: var(--panel); - border-radius: 3px; +<style lang="scss" module> +.choices { + display: block; + margin: 0; + padding: 0; + list-style: none; +} - > i { - margin-right: 4px; - color: var(--accent); - } +.choice { + display: block; + position: relative; + margin: 4px 0; + padding: 4px; + //border: solid 0.5px var(--divider); + background: var(--accentedBg); + border-radius: 4px; + overflow: clip; + cursor: pointer; +} - > .votes { - margin-left: 4px; - opacity: 0.7; - } - } - } - } +.bg { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); + transition: width 1s ease; +} - > p { - color: var(--fg); +.fg { + position: relative; + display: inline-block; + padding: 3px 5px; + background: var(--panel); + border-radius: 3px; +} - a { - color: inherit; - } - } +.info { + color: var(--fg); +} - &.done { - > ul > li { - cursor: default; - } +.done { + .choice { + cursor: default; } } </style> diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 471ec39169..2da9339944 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -5,7 +5,7 @@ </p> <ul> <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" small :model-value="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)"> + <MkInput class="input" small :modelValue="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> </MkInput> <button class="_button" @click="remove(i)"> <i class="ti ti-x"></i> diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 93b9eb401d..30af365669 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -1,6 +1,6 @@ <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @close="emit('closing')" @closed="emit('closed')"> - <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="modal.close()"/> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :zPriority="'high'" :src="src" :transparentBg="true" @click="modal.close()" @close="emit('closing')" @closed="emit('closed')"> + <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="modal.close()"/> </MkModal> </template> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c65cb7d6e5..5c65569683 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -22,21 +22,21 @@ <span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span> <span :class="$style.headerRightButtonText">{{ i18n.ts._visibility[visibility] }}</span> </button> - <button v-else :class="['_button', $style.headerRightItem, $style.visibility]" disabled> + <button v-else class="_button" :class="[$style.headerRightItem, $style.visibility]" disabled> <span><i class="ti ti-device-tv"></i></span> <span :class="$style.headerRightButtonText">{{ channel.name }}</span> </button> </template> - <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" :class="['_button', $style.headerRightItem, $style.localOnly, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly"> + <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly"> <span v-if="!localOnly"><i class="ti ti-rocket"></i></span> <span v-else><i class="ti ti-rocket-off"></i></span> </button> - <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" :class="['_button', $style.headerRightItem, $style.reactionAcceptance, { [$style.danger]: reactionAcceptance }]" @click="toggleReactionAcceptance"> + <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> <span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span> <span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span> <span v-else><i class="ti ti-icons"></i></span> </button> - <button v-click-anime class="_button" :class="[$style.submit, { [$style.submitPosting]: posting }]" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> + <button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> <div :class="$style.submitInner"> <template v-if="posted"></template> <template v-else-if="posting"><MkEllipsis/></template> @@ -66,7 +66,7 @@ <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> - <XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> + <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> <div v-if="showingOptions" style="padding: 8px 16px;"> @@ -484,8 +484,10 @@ async function toggleReactionAcceptance() { title: i18n.ts.reactionAcceptance, items: [ { value: null, text: i18n.ts.all }, - { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, { value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote }, + { value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly }, + { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, + { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, ], default: reactionAcceptance, }); diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 760c6e5d08..18fa142ebc 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -1,16 +1,16 @@ <template> -<div v-show="props.modelValue.length != 0" class="skeikyzd"> - <Sortable :model-value="props.modelValue" class="files" item-key="id" :animation="150" :delay="100" :delay-on-touch-only="true" @update:model-value="v => emit('update:modelValue', v)"> +<div v-show="props.modelValue.length != 0" :class="$style.root"> + <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)"> <template #item="{element}"> - <div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> - <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> - <div v-if="element.isSensitive" class="sensitive"> - <i class="ti ti-alert-triangle icon"></i> + <div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> + <div v-if="element.isSensitive" :class="$style.sensitive"> + <i class="ti ti-alert-triangle" style="margin: auto;"></i> </div> </div> </template> </Sortable> - <p class="remain">{{ 16 - props.modelValue.length }}/16</p> + <p :class="$style.remain">{{ 16 - props.modelValue.length }}/16</p> </div> </template> @@ -93,7 +93,7 @@ function showFileMenu(file, ev: MouseEvent) { action: () => { rename(file); }, }, { text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, - icon: file.isSensitive ? 'ti ti-eye-off' : 'ti ti-eye', + icon: file.isSensitive ? 'ti ti-eye-exclamation' : 'ti ti-eye', action: () => { toggleSensitive(file); }, }, { text: i18n.ts.describeFile, @@ -108,60 +108,53 @@ function showFileMenu(file, ev: MouseEvent) { } </script> -<style lang="scss" scoped> -.skeikyzd { +<style lang="scss" module> +.root { padding: 8px 16px; position: relative; +} - > .files { - display: flex; - flex-wrap: wrap; - - > .file { - position: relative; - width: 64px; - height: 64px; - margin-right: 4px; - border-radius: 4px; - overflow: hidden; - cursor: move; - - &:hover > .remove { - display: block; - } +.files { + display: flex; + flex-wrap: wrap; +} - > .thumbnail { - width: 100%; - height: 100%; - z-index: 1; - color: var(--fg); - } +.file { + position: relative; + width: 64px; + height: 64px; + margin-right: 4px; + border-radius: 4px; + overflow: hidden; + cursor: move; +} - > .sensitive { - display: flex; - position: absolute; - width: 64px; - height: 64px; - top: 0; - left: 0; - z-index: 2; - background: rgba(17, 17, 17, .7); - color: #fff; +.thumbnail { + width: 100%; + height: 100%; + z-index: 1; + color: var(--fg); +} - > .icon { - margin: auto; - } - } - } - } +.sensitive { + display: flex; + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 2; + background: rgba(17, 17, 17, .7); + color: #fff; +} - > .remain { - display: block; - position: absolute; - top: 8px; - right: 8px; - margin: 0; - padding: 0; - } +.remain { + display: block; + position: absolute; + top: 8px; + right: 8px; + margin: 0; + padding: 0; + font-size: 90%; } </style> diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 6326c498d7..98af92c6f8 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -1,6 +1,6 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" @click="modal.close()" @closed="onModalClosed()"> - <MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freeze-after-posted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> +<MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()"> + <MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> </MkModal> </template> diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index b98c814f24..448084d9ba 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -72,28 +72,28 @@ function subscribe() { userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(instance.swPublickey), }) - .then(async subscription => { - pushSubscription = subscription; + .then(async subscription => { + pushSubscription = subscription; - // Register - pushRegistrationInServer = await api('sw/register', { - endpoint: subscription.endpoint, - auth: encode(subscription.getKey('auth')), - publickey: encode(subscription.getKey('p256dh')), - }); - }, async err => { // When subscribe failed + // Register + pushRegistrationInServer = await api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')), + }); + }, async err => { // When subscribe failed // 通知が許可されていなかったとき - if (err?.name === 'NotAllowedError') { - console.info('User denied the notification permission request.'); - return; - } + if (err?.name === 'NotAllowedError') { + console.info('User denied the notification permission request.'); + return; + } - // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが - // 既に存在していることが原因でエラーになった可能性があるので、 - // そのサブスクリプションを解除しておく - // (これは実行されなさそうだけど、おまじない的に古い実装から残してある) - await unsubscribe(); - }), null, null); + // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが + // 既に存在していることが原因でエラーになった可能性があるので、 + // そのサブスクリプションを解除しておく + // (これは実行されなさそうだけど、おまじない的に古い実装から残してある) + await unsubscribe(); + }), null, null); } async function unsubscribe() { diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index e2240fb4e1..84be10078a 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -1,37 +1,27 @@ <script lang="ts"> -import { VNode, defineComponent, h } from 'vue'; +import { VNode, defineComponent, h, ref, watch } from 'vue'; import MkRadio from './MkRadio.vue'; export default defineComponent({ - components: { - MkRadio, - }, props: { modelValue: { required: false, }, }, - data() { - return { - value: this.modelValue, - }; - }, - watch: { - value() { - this.$emit('update:modelValue', this.value); - }, - }, - render() { - console.log(this.$slots, this.$slots.label && this.$slots.label()); - if (!this.$slots.default) return null; - let options = this.$slots.default(); - const label = this.$slots.label && this.$slots.label(); - const caption = this.$slots.caption && this.$slots.caption(); + setup(props, context) { + const value = ref(props.modelValue); + watch(value, () => { + context.emit('update:modelValue', value.value); + }); + if (!context.slots.default) return null; + let options = context.slots.default(); + const label = context.slots.label && context.slots.label(); + const caption = context.slots.caption && context.slots.caption(); // なぜかFragmentになることがあるため if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[]; - return h('div', { + return () => h('div', { class: 'novjtcto', }, [ ...(label ? [h('div', { @@ -42,8 +32,8 @@ export default defineComponent({ }, options.map(option => h(MkRadio, { key: option.key, value: option.props?.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, + modelValue: value.value, + 'onUpdate:modelValue': _v => value.value = _v, }, () => option.children)), ), ...(caption ? [h('div', { diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue index 0c0cc36692..cd2a359d5c 100644 --- a/packages/frontend/src/components/MkReactedUsersDialog.vue +++ b/packages/frontend/src/components/MkReactedUsersDialog.vue @@ -8,7 +8,7 @@ > <template #header>{{ i18n.ts.reactionsList }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <div v-if="note" class="_gaps"> <div v-if="reactions.length === 0" class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> @@ -22,7 +22,7 @@ </button> </div> <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()"> - <MkUserCardMini :user="user" :with-chart="false"/> + <MkUserCardMini :user="user" :withChart="false"/> </MkA> </template> </div> diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 29b3f9b85b..dfb06f63c4 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -1,6 +1,6 @@ <template> -<MkCustomEmoji v-if="reaction[0] === ':'" :name="reaction" :normal="true" :no-style="noStyle" :url="emojiUrl"/> -<MkEmoji v-else :emoji="reaction" :normal="true" :no-style="noStyle"/> +<MkCustomEmoji v-if="reaction[0] === ':'" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/> +<MkEmoji v-else :emoji="reaction" :normal="true" :noStyle="noStyle"/> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue index 4d67dc3da2..34afa72232 100644 --- a/packages/frontend/src/components/MkReactionTooltip.vue +++ b/packages/frontend/src/components/MkReactionTooltip.vue @@ -1,7 +1,7 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')"> <div :class="$style.root"> - <MkReactionIcon :reaction="reaction" :class="$style.icon" :no-style="true"/> + <MkReactionIcon :reaction="reaction" :class="$style.icon" :noStyle="true"/> <div :class="$style.name">{{ reaction.replace('@.', '') }}</div> </div> </MkTooltip> diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index f5e611c62a..99960f5d25 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -1,8 +1,8 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')"> <div :class="$style.root"> <div :class="$style.reaction"> - <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :no-style="true"/> + <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :noStyle="true"/> <div :class="$style.reactionName">{{ getReactionName(reaction) }}</div> </div> <div :class="$style.users"> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 9480af5102..aabebb3abf 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -6,7 +6,7 @@ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]" @click="toggleReaction()" > - <MkReactionIcon :class="$style.icon" :reaction="reaction" :emoji-url="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/> + <MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/> <span :class="$style.count">{{ count }}</span> </button> </template> @@ -22,6 +22,7 @@ import { $i } from '@/account'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; import { claimAchievement } from '@/scripts/achievements'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const props = defineProps<{ reaction: string; @@ -34,11 +35,19 @@ const buttonEl = shallowRef<HTMLElement>(); const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); -const toggleReaction = () => { +async function toggleReaction() { if (!canToggle.value) return; + // TODO: その絵文字を使う権限があるかどうか確認 + const oldReaction = props.note.myReaction; if (oldReaction) { + const confirm = await os.confirm({ + type: 'warning', + text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm, + }); + if (confirm.canceled) return; + os.api('notes/reactions/delete', { noteId: props.note.id, }).then(() => { @@ -58,9 +67,9 @@ const toggleReaction = () => { claimAchievement('reactWithoutRead'); } } -}; +} -const anime = () => { +function anime() { if (document.hidden) return; if (!defaultStore.state.animation) return; @@ -68,7 +77,7 @@ const anime = () => { const x = rect.left + 16; const y = rect.top + (buttonEl.value.offsetHeight / 2); os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end'); -}; +} watch(() => props.count, (newCount, oldCount) => { if (oldCount < newCount) anime(); diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 3219c8a92c..ce146463ec 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -1,13 +1,13 @@ <template> <TransitionGroup - :enter-active-class="defaultStore.state.animation ? $style.transition_x_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_x_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_x_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_x_leaveTo : ''" - :move-class="defaultStore.state.animation ? $style.transition_x_move : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_x_leaveTo : ''" + :moveClass="defaultStore.state.animation ? $style.transition_x_move : ''" tag="div" :class="$style.root" > - <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/> + <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note"/> <slot v-if="hasMoreReactions" name="more"/> </TransitionGroup> </template> diff --git a/packages/frontend/src/components/MkRenotedUsersDialog.vue b/packages/frontend/src/components/MkRenotedUsersDialog.vue index 56025535f1..814a68d4da 100644 --- a/packages/frontend/src/components/MkRenotedUsersDialog.vue +++ b/packages/frontend/src/components/MkRenotedUsersDialog.vue @@ -8,7 +8,7 @@ > <template #header>{{ i18n.ts.renotesList }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <div v-if="renotes" class="_gaps"> <div v-if="renotes.length === 0" class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> @@ -16,7 +16,7 @@ </div> <template v-else> <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()"> - <MkUserCardMini :user="user" :with-chart="false"/> + <MkUserCardMini :user="user" :withChart="false"/> </MkA> </template> </div> diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index 8bd0279806..9f56189f3e 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -124,7 +124,3 @@ onMounted(async () => { }); }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/components/MkRippleEffect.vue b/packages/frontend/src/components/MkRippleEffect.vue index 9d93211d5f..60c3a47385 100644 --- a/packages/frontend/src/components/MkRippleEffect.vue +++ b/packages/frontend/src/components/MkRippleEffect.vue @@ -1,7 +1,7 @@ <template> -<div class="vswabwbm" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> +<div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> - <circle fill="none" cx="64" cy="64"> + <circle fill="none" cx="64" cy="64" style="stroke: var(--accent);"> <animate attributeName="r" begin="0s" dur="0.5s" @@ -22,7 +22,7 @@ /> </circle> <g fill="none" fill-rule="evenodd"> - <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> + <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color" style="stroke: var(--accent);"> <animate attributeName="r" begin="0s" dur="0.8s" @@ -100,17 +100,11 @@ onMounted(() => { }); </script> -<style lang="scss" scoped> -.vswabwbm { +<style lang="scss" module> +.root { pointer-events: none; position: fixed; width: 128px; height: 128px; - - > svg { - > circle { - stroke: var(--accent); - } - } } </style> diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 2f5866f340..9fbe1ec993 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -12,8 +12,10 @@ </template> </span> <span :class="$style.name">{{ role.name }}</span> - <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> - <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span> + <template v-if="detailed"> + <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> + <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span> + </template> </div> <div :class="$style.description">{{ role.description }}</div> </MkA> @@ -23,10 +25,13 @@ import { } from 'vue'; import { i18n } from '@/i18n'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ role: any; forModeration: boolean; -}>(); + detailed: boolean; +}>(), { + detailed: true, +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue deleted file mode 100644 index 922b862b47..0000000000 --- a/packages/frontend/src/components/MkSample.vue +++ /dev/null @@ -1,118 +0,0 @@ -<template> -<div class=""> - <div class=""> - <MkInput v-model="text"> - <template #label>Text</template> - </MkInput> - <MkSwitch v-model="flag"> - <span>Switch is now {{ flag ? 'on' : 'off' }}</span> - </MkSwitch> - <div style="margin: 32px 0;"> - <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> - <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> - <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> - </div> - <MkButton inline>This is</MkButton> - <MkButton inline primary>the button</MkButton> - </div> - <div class="" style="pointer-events: none;"> - <Mfm :text="mfm"/> - </div> - <div class=""> - <MkButton inline primary @click="openMenu">Open menu</MkButton> - <MkButton inline primary @click="openDialog">Open dialog</MkButton> - <MkButton inline primary @click="openForm">Open form</MkButton> - <MkButton inline primary @click="openDrive">Open drive</MkButton> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; -import MkRadio from '@/components/MkRadio.vue'; -import * as os from '@/os'; -import * as config from '@/config'; -import { $i } from '@/account'; - -export default defineComponent({ - components: { - MkButton, - MkInput, - MkSwitch, - MkTextarea, - MkRadio, - }, - - data() { - return { - text: '', - flag: true, - radio: 'misskey', - $i, - mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`, - }; - }, - - methods: { - async openDialog() { - os.alert({ - type: 'warning', - title: 'Oh my Aichan', - text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }); - }, - - async openForm() { - os.form('Example form', { - foo: { - type: 'boolean', - default: true, - label: 'This is a boolean property', - }, - bar: { - type: 'number', - default: 300, - label: 'This is a number property', - }, - baz: { - type: 'string', - default: 'Misskey makes you happy.', - label: 'This is a string property', - }, - }); - }, - - async openDrive() { - os.selectDriveFile(false); - }, - - async selectUser() { - os.selectUser(); - }, - - async openMenu(ev) { - os.popupMenu([{ - type: 'label', - text: 'Fruits', - }, { - text: 'Create some apples', - action: () => {}, - }, { - text: 'Read some oranges', - action: () => {}, - }, { - text: 'Update some melons', - action: () => {}, - }, null, { - text: 'Delete some bananas', - danger: true, - action: () => {}, - }], ev.currentTarget ?? ev.target); - }, - }, -}); -</script> diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index ffc5e82b56..b1a509b9e6 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -1,16 +1,16 @@ <template> -<form class="eppvobhk" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> - <div class="auth _gaps_m"> - <div v-show="withAvatar" class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div> +<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> + <div class="_gaps_m"> + <div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div> <MkInfo v-if="message"> {{ message }} </MkInfo> <div v-if="!totpLogin" class="normal-signin _gaps_m"> - <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:model-value="onUsernameChange"> + <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> - <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> + <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :withPasswordToggle="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,7 +28,7 @@ </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" autocomplete="current-password" :with-password-toggle="true" required> + <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required> <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="ti ti-lock"></i></template> </MkInput> @@ -236,18 +236,14 @@ function resetPassword() { } </script> -<style lang="scss" scoped> -.eppvobhk { - > .auth { - > .avatar { - margin: 0 auto 0 auto; - width: 64px; - height: 64px; - background: #ddd; - background-position: center; - background-size: cover; - border-radius: 100%; - } - } +<style lang="scss" module> +.avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; } </style> diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 08e41d6ae5..eb5876e584 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -8,8 +8,8 @@ > <template #header>{{ i18n.ts.login }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> - <MkSignin :auto-set="autoSet" :message="message" @login="onLogin"/> + <MkSpacer :marginMin="20" :marginMax="28"> + <MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/> </MkSpacer> </MkModalWindow> </template> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 0e8bdb321e..472269abaf 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -3,13 +3,13 @@ <div :class="$style.banner"> <i class="ti ti-user-edit"></i> </div> - <MkSpacer :margin-min="20" :margin-max="32"> + <MkSpacer :marginMin="20" :marginMax="32"> <form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit"> <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required> <template #label>{{ i18n.ts.invitationCode }}</template> <template #prefix><i class="ti ti-key"></i></template> </MkInput> - <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername"> + <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" autocomplete="username" required data-cy-signup-username @update:modelValue="onChangeUsername"> <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> @@ -24,7 +24,7 @@ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span> </template> </MkInput> - <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail"> + <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template> <template #prefix><i class="ti ti-mail"></i></template> <template #caption> @@ -39,7 +39,7 @@ <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> </template> </MkInput> - <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword"> + <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword"> <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="ti ti-lock"></i></template> <template #caption> @@ -48,7 +48,7 @@ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span> </template> </MkInput> - <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype"> + <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype"> <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template> <template #prefix><i class="ti ti-lock"></i></template> <template #caption> diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index 6da81c3bcb..b6ffba6cc7 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -3,7 +3,7 @@ <div :class="$style.banner"> <i class="ti ti-checklist"></i> </div> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps_m"> <div v-if="instance.disableRegistration"> <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> @@ -11,7 +11,7 @@ <div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div> - <MkFolder v-if="availableServerRules" :default-open="true"> + <MkFolder v-if="availableServerRules" :defaultOpen="true"> <template #label>{{ i18n.ts.serverRules }}</template> <template #suffix><i v-if="agreeServerRules" class="ti ti-check" style="color: var(--success)"></i></template> @@ -22,7 +22,7 @@ <MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> - <MkFolder v-if="availableTos" :default-open="true"> + <MkFolder v-if="availableTos" :defaultOpen="true"> <template #label>{{ i18n.ts.termsOfService }}</template> <template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template> @@ -31,7 +31,7 @@ <MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template> <template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--success)"></i></template> diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 17f8b86425..d8d002fdb6 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -11,16 +11,16 @@ <div style="overflow-x: clip;"> <Transition mode="out-in" - :enter-active-class="$style.transition_x_enterActive" - :leave-active-class="$style.transition_x_leaveActive" - :enter-from-class="$style.transition_x_enterFrom" - :leave-to-class="$style.transition_x_leaveTo" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" > <template v-if="!isAcceptedServerRule"> <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/> </template> <template v-else> - <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/> + <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> </template> </Transition> </div> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 1ac7107aa7..3a050889c8 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -1,15 +1,15 @@ <template> <div :class="[$style.root, { [$style.collapsed]: collapsed }]"> - <div :class="$style.body"> + <div> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :emoji-urls="note.emojis"/> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :emojiUrls="note.emojis"/> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> <details v-if="note.files.length > 0"> <summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary> - <MkMediaList :media-list="note.files"/> + <MkMediaList :mediaList="note.files"/> </details> <details v-if="note.poll"> <summary>{{ i18n.ts.poll }}</summary> @@ -76,10 +76,6 @@ const collapsed = $ref( } } -.body { - -} - .reply { margin-right: 6px; color: var(--accent); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 2a8e43c570..72b70416d9 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -23,22 +23,13 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; -export default defineComponent({ - props: { - def: { - type: Array, - required: true, - }, - grid: { - type: Boolean, - required: false, - default: false, - }, - }, -}); +defineProps<{ + def: any[]; + grid?: boolean; +}>(); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue index 6f819bbbd7..7274f9b310 100644 --- a/packages/frontend/src/components/MkTab.vue +++ b/packages/frontend/src/components/MkTab.vue @@ -7,17 +7,17 @@ export default defineComponent({ required: true, }, }, - render() { - const options = this.$slots.default(); + setup(props, { emit, slots }) { + const options = slots.default(); - return h('div', { + return () => h('div', { class: 'pxhvhrfw', }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: this.modelValue === option.props.value }], + class: ['_button', { active: props.modelValue === option.props.value }], key: option.key, - disabled: this.modelValue === option.props.value, + disabled: props.modelValue === option.props.value, onClick: () => { - this.$emit('update:modelValue', option.props.value); + emit('update:modelValue', option.props.value); }, }, option.children), [ [resolveDirective('click-anime')], diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 4e8d5bab7f..6e4e054aad 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -1,7 +1,7 @@ <template> -<div ref="rootEl" class="meijqfqm"> - <canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas> - <div :id="idForTags" ref="tagsEl" class="tags"> +<div ref="rootEl" :class="$style.root"> + <canvas :id="idForCanvas" ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas> + <div :id="idForTags" ref="tagsEl" :class="$style.tags"> <ul> <slot></slot> </ul> @@ -70,21 +70,17 @@ defineExpose({ }); </script> -<style lang="scss" scoped> -.meijqfqm { +<style lang="scss" module> +.root { position: relative; overflow: clip; display: grid; place-items: center; +} - > .canvas { - display: block; - } - - > .tags { - position: absolute; - top: 999px; - left: 999px; - } +.tags { + position: absolute; + top: 999px; + left: 999px; } </style> diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 82b631edda..83b2ed2444 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -1,12 +1,12 @@ <template> -<div class="adhpbeos"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ disabled, focused, tall, pre }"> +<div> + <div :class="$style.label" @click="focus"><slot name="label"></slot></div> + <div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;"> <textarea ref="inputEl" v-model="v" v-adaptive-border - :class="{ code, _monospace: code }" + :class="[$style.textarea, { _monospace: code }]" :disabled="disabled" :required="required" :readonly="readonly" @@ -20,243 +20,173 @@ @input="onInput" ></textarea> </div> - <div class="caption"><slot name="caption"></slot></div> + <div :class="$style.caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +<script lang="ts" setup> +import { onMounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - }, +const props = defineProps<{ + modelValue: string | null; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + pattern?: string; + placeholder?: string; + autofocus?: boolean; + autocomplete?: string; + spellcheck?: boolean; + debounce?: boolean; + manualSave?: boolean; + code?: boolean; + tall?: boolean; + pre?: boolean; +}>(); - props: { - modelValue: { - required: true, - }, - type: { - type: String, - required: false, - }, - required: { - type: Boolean, - required: false, - }, - readonly: { - type: Boolean, - required: false, - }, - disabled: { - type: Boolean, - required: false, - }, - pattern: { - type: String, - required: false, - }, - placeholder: { - type: String, - required: false, - }, - autofocus: { - type: Boolean, - required: false, - default: false, - }, - autocomplete: { - required: false, - }, - spellcheck: { - required: false, - }, - code: { - type: Boolean, - required: false, - }, - tall: { - type: Boolean, - required: false, - default: false, - }, - pre: { - type: Boolean, - required: false, - default: false, - }, - debounce: { - type: Boolean, - required: false, - default: false, - }, - manualSave: { - type: Boolean, - required: false, - default: false, - }, - }, +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'keydown', _ev: KeyboardEvent): void; + (ev: 'enter'): void; + (ev: 'update:modelValue', value: string): void; +}>(); - emits: ['change', 'keydown', 'enter', 'update:modelValue'], +const { modelValue, autofocus } = toRefs(props); +const v = ref<string>(modelValue.value ?? ''); +const focused = ref(false); +const changed = ref(false); +const invalid = ref(false); +const filled = computed(() => v.value !== '' && v.value != null); +const inputEl = shallowRef<HTMLTextAreaElement>(); - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); +const focus = () => inputEl.value.focus(); +const onInput = (ev) => { + changed.value = true; + emit('change', ev); +}; +const onKeydown = (ev: KeyboardEvent) => { + if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; + emit('keydown', ev); - context.emit('keydown', ev); - - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; - - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; + if (ev.code === 'Enter') { + emit('enter'); + } +}; - const debouncedUpdated = debounce(1000, updated); +const updated = () => { + changed.value = false; + emit('update:modelValue', v.value ?? ''); +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +const debouncedUpdated = debounce(1000, updated); - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } +watch(modelValue, newValue => { + v.value = newValue; +}); - invalid.value = inputEl.value.validity.badInput; - }); +watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - }); - }); + invalid.value = inputEl.value.validity.badInput; +}); - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - focus, - onInput, - onKeydown, - updated, - i18n, - }; - }, +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); }); </script> -<style lang="scss" scoped> -.adhpbeos { - > .label { - font-size: 0.85em; - padding: 0 0 8px 0; - user-select: none; +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .caption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: var(--fgTransparentWeak); +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .input { - position: relative; - - > textarea { - appearance: none; - -webkit-appearance: none; - display: block; - width: 100%; - min-width: 100%; - max-width: 100%; - min-height: 130px; - margin: 0; - padding: 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover) !important; - } - } +.textarea { + appearance: none; + -webkit-appearance: none; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; - &.focused { - > textarea { - border-color: var(--accent) !important; - } - } + &:hover { + border-color: var(--inputBorderHover) !important; + } +} - &.disabled { - opacity: 0.7; +.focused { + > .textarea { + border-color: var(--accent) !important; + } +} - &, * { - cursor: not-allowed !important; - } - } +.disabled { + opacity: 0.7; + cursor: not-allowed !important; - &.tall { - > textarea { - min-height: 200px; - } - } + > .textarea { + cursor: not-allowed !important; + } +} - &.pre { - > textarea { - white-space: pre; - } - } +.tall { + > .textarea { + min-height: 200px; } +} - > .save { - margin: 8px 0 0 0; +.pre { + > .textarea { + white-space: pre; } } + +.save { + margin: 8px 0 0 0; +} </style> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index fb0a3a4b67..2595ebc45d 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -1,11 +1,11 @@ <template> -<MkNotes ref="tlComponent" :no-gap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> +<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> </template> <script lang="ts" setup> import { computed, provide, onUnmounted } from 'vue'; import MkNotes from '@/components/MkNotes.vue'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import * as sound from '@/scripts/sound'; import { $i } from '@/account'; import { defaultStore } from '@/store'; @@ -46,17 +46,13 @@ const onUserRemoved = () => { tlComponent.pagingComponent?.reload(); }; -const onChangeFollowing = () => { - if (!tlComponent.pagingComponent?.backed) { - tlComponent.pagingComponent?.reload(); - } -}; - let endpoint; let query; let connection; let connection2; +const stream = useStream(); + if (props.src === 'antenna') { endpoint = 'antennas/notes'; query = { @@ -68,23 +64,41 @@ if (props.src === 'antenna') { connection.on('note', prepend); } else if (props.src === 'home') { endpoint = 'notes/timeline'; - connection = stream.useChannel('homeTimeline'); + query = { + withReplies: defaultStore.state.showTimelineReplies, + }; + connection = stream.useChannel('homeTimeline', { + withReplies: defaultStore.state.showTimelineReplies, + }); connection.on('note', prepend); connection2 = stream.useChannel('main'); - connection2.on('follow', onChangeFollowing); - connection2.on('unfollow', onChangeFollowing); } else if (props.src === 'local') { endpoint = 'notes/local-timeline'; - connection = stream.useChannel('localTimeline'); + query = { + withReplies: defaultStore.state.showTimelineReplies, + }; + connection = stream.useChannel('localTimeline', { + withReplies: defaultStore.state.showTimelineReplies, + }); connection.on('note', prepend); } else if (props.src === 'social') { endpoint = 'notes/hybrid-timeline'; - connection = stream.useChannel('hybridTimeline'); + query = { + withReplies: defaultStore.state.showTimelineReplies, + }; + connection = stream.useChannel('hybridTimeline', { + withReplies: defaultStore.state.showTimelineReplies, + }); connection.on('note', prepend); } else if (props.src === 'global') { endpoint = 'notes/global-timeline'; - connection = stream.useChannel('globalTimeline'); + query = { + withReplies: defaultStore.state.showTimelineReplies, + }; + connection = stream.useChannel('globalTimeline', { + withReplies: defaultStore.state.showTimelineReplies, + }); connection.on('note', prepend); } else if (props.src === 'mentions') { endpoint = 'notes/mentions'; diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index ad53c7f289..e135f56472 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -1,11 +1,11 @@ <template> <div> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_toast_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''" - appear @after-leave="emit('closed')" + :enterActiveClass="defaultStore.state.animation ? $style.transition_toast_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''" + appear @afterLeave="emit('closed')" > <div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }"> <div style="padding: 16px 24px;"> diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 56be044405..3ddd81aaee 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -3,16 +3,16 @@ ref="dialog" :width="400" :height="450" - :with-ok-button="true" - :ok-button-disabled="false" - :can-close="false" + :withOkButton="true" + :okButtonDisabled="false" + :canClose="false" @close="dialog.close()" @closed="$emit('closed')" @ok="ok()" > <template #header>{{ title || i18n.ts.generateAccessToken }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps_m"> <div v-if="information"> <MkInfo warn>{{ information }}</MkInfo> diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 2d34b090ed..91c9b70a5a 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -1,10 +1,10 @@ <template> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''" - appear @after-leave="emit('closed')" + :enterActiveClass="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''" + appear @afterLeave="emit('closed')" > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> <slot> @@ -41,6 +41,9 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); +// タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる +if (!props.showing) emit('closed'); + const el = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex('high'); @@ -66,10 +69,8 @@ onMounted(() => { setPosition(); const loop = () => { - loopHandler = window.requestAnimationFrame(() => { - setPosition(); - loop(); - }); + setPosition(); + loopHandler = window.requestAnimationFrame(loop); }; loop(); diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index eed7fa71f6..3a0b2abb4e 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -1,5 +1,5 @@ <template> -<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> +<MkModal ref="modal" :zPriority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 9c5622b1c5..fcad5b8064 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -22,7 +22,7 @@ </div> </template> <template v-else-if="tweetId && tweetExpanded"> - <div ref="twitter" :class="$style.twitter"> + <div ref="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=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> </div> <div :class="$style.action"> @@ -31,7 +31,7 @@ </MkButton> </div> </template> -<div v-else :class="$style.urlPreview"> +<div v-else> <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}')`"> </div> @@ -41,14 +41,14 @@ <h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1> <h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1> </header> - <p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.cannotLoad }}</p> + <p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.failedToPreviewUrl }}</p> <p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p> <p v-else-if="description" :class="$style.text" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> <footer :class="$style.footer"> <img v-if="icon" :class="$style.siteIcon" :src="icon"/> - <p v-if="unknownUrl" :class="$style.siteName">?</p> + <p v-if="unknownUrl" :class="$style.siteName">{{ requestUrl.host }}</p> <p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p> - <p v-else :class="$style.siteName" :title="sitename ?? undefined">{{ sitename }}</p> + <p v-else :class="$style.siteName" :title="sitename ?? requestUrl.host">{{ sitename ?? requestUrl.host }}</p> </footer> </article> </component> @@ -128,17 +128,33 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/ requestUrl.hash = ''; -window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => { - res.json().then((info: SummalyResult) => { +window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) + .then(res => { + if (!res.ok) { + fetching = false; + unknownUrl = true; + return; + } + + return res.json(); + }) + .then((info: SummalyResult) => { + if (info.url == null) { + fetching = false; + unknownUrl = true; + return; + } + + fetching = false; + unknownUrl = false; + title = info.title; description = info.description; thumbnail = info.thumbnail; icon = info.icon; sitename = info.sitename; - fetching = false; player = info.player; }); -}); function adjustTweetHeight(message: any) { if (message.origin !== 'https://platform.twitter.com') return; @@ -194,13 +210,6 @@ onUnmounted(() => { width: 100%; } -.twitter { - -} - -.urlPreview { -} - .link { position: relative; display: block; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index e244be3e96..36a9e2f73f 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -1,6 +1,6 @@ <template> -<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @after-leave="emit('closed')"> +<div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> + <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/> </Transition> </div> @@ -36,8 +36,8 @@ onMounted(() => { }); </script> -<style lang="scss" scoped> -.fgmtyycl { +<style lang="scss" module> +.root { position: absolute; width: 500px; max-width: calc(90vw - 12px); diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index f560ebcd8a..172b517511 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -8,7 +8,7 @@ </div> <span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> <div :class="$style.description"> - <div v-if="user.description" class="mfm"> + <div v-if="user.description" :class="$style.mfm"> <Mfm :text="user.description" :author="user" :i="$i"/> </div> <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span> @@ -105,7 +105,7 @@ defineProps<{ .mfm { display: -webkit-box; -webkit-line-clamp: 3; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; overflow: hidden; } diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue index 251ab5d79a..a2c2b53b08 100644 --- a/packages/frontend/src/components/MkUserOnlineIndicator.vue +++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue @@ -1,5 +1,13 @@ <template> -<div v-tooltip="text" :class="[$style.root, $style['status_' + user.onlineStatus]]"></div> +<div + v-tooltip="text" + :class="[$style.root, { + [$style.status_online]: user.onlineStatus === 'online', + [$style.status_active]: user.onlineStatus === 'active', + [$style.status_offline]: user.onlineStatus === 'offline', + [$style.status_unknown]: user.onlineStatus === 'unknown', + }]" +></div> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 8ca0355448..c3b777a12e 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -1,10 +1,10 @@ <template> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_popup_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''" - appear @after-leave="emit('closed')" + :enterActiveClass="defaultStore.state.animation ? $style.transition_popup_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''" + appear @afterLeave="emit('closed')" > <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> <div v-if="user != null"> @@ -22,7 +22,7 @@ <div :class="$style.username"><MkAcct :user="user"/></div> </div> <div :class="$style.description"> - <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i"/> + <Mfm v-if="user.description" :class="$style.mfm" :text="user.description" :author="user" :i="$i"/> <div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div> </div> <div :class="$style.status"> @@ -192,6 +192,13 @@ onMounted(() => { border-bottom: solid 1px var(--divider); } +.mfm { + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + overflow: hidden; +} + .status { padding: 16px 26px 16px 26px; } diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index dc78bbf42d..792ff7afd7 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -1,22 +1,22 @@ <template> <MkModalWindow ref="dialogEl" - :with-ok-button="true" - :ok-button-disabled="selected == null" + :withOkButton="true" + :okButtonDisabled="selected == null" @click="cancel()" @close="cancel()" @ok="ok()" @closed="$emit('closed')" > <template #header>{{ i18n.ts.selectUser }}</template> - <div :class="$style.root"> + <div> <div :class="$style.form"> - <FormSplit :min-width="170"> - <MkInput v-model="username" :autofocus="true" @update:model-value="search"> + <FormSplit :minWidth="170"> + <MkInput v-model="username" :autofocus="true" @update:modelValue="search"> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> </MkInput> - <MkInput v-model="host" :datalist="[hostname]" @update:model-value="search"> + <MkInput v-model="host" :datalist="[hostname]" @update:modelValue="search"> <template #label>{{ i18n.ts.host }}</template> <template #prefix>@</template> </MkInput> @@ -126,8 +126,6 @@ onMounted(() => { </script> <style lang="scss" module> -.root { -} .form { padding: 0 var(--root-margin); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index a2a195cb09..789f88a8fe 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -2,7 +2,7 @@ <div class="_gaps"> <div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.recommended }}</template> <MkPagination :pagination="pinnedUsers"> @@ -14,7 +14,7 @@ </MkPagination> </MkFolder> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.popularUsers }}</template> <MkPagination :pagination="popularUsers"> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index e9f4f68df8..5cea67ccf5 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -4,6 +4,7 @@ <MkFolder> <template #label>{{ i18n.ts.makeFollowManuallyApprove }}</template> + <template #icon><i class="ti ti-lock"></i></template> <template #suffix>{{ isLocked ? i18n.ts.on : i18n.ts.off }}</template> <MkSwitch v-model="isLocked">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch> @@ -11,6 +12,7 @@ <MkFolder> <template #label>{{ i18n.ts.hideOnlineStatus }}</template> + <template #icon><i class="ti ti-eye-off"></i></template> <template #suffix>{{ hideOnlineStatus ? i18n.ts.on : i18n.ts.off }}</template> <MkSwitch v-model="hideOnlineStatus">{{ i18n.ts.hideOnlineStatus }}<template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template></MkSwitch> @@ -18,6 +20,7 @@ <MkFolder> <template #label>{{ i18n.ts.noCrawle }}</template> + <template #icon><i class="ti ti-world-x"></i></template> <template #suffix>{{ noCrawle ? i18n.ts.on : i18n.ts.off }}</template> <MkSwitch v-model="noCrawle">{{ i18n.ts.noCrawle }}<template #caption>{{ i18n.ts.noCrawleDescription }}</template></MkSwitch> @@ -25,6 +28,7 @@ <MkFolder> <template #label>{{ i18n.ts.preventAiLearning }}</template> + <template #icon><i class="ti ti-photo-shield"></i></template> <template #suffix>{{ preventAiLearning ? i18n.ts.on : i18n.ts.off }}</template> <MkSwitch v-model="preventAiLearning">{{ i18n.ts.preventAiLearning }}<template #caption>{{ i18n.ts.preventAiLearningDescription }}</template></MkSwitch> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index f26ea11214..3107209b97 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -12,11 +12,11 @@ </div> </FormSlot> - <MkInput v-model="name" :max="30" manual-save data-cy-user-setup-user-name> + <MkInput v-model="name" :max="30" manualSave data-cy-user-setup-user-name> <template #label>{{ i18n.ts._profile.name }}</template> </MkInput> - <MkTextarea v-model="description" :max="500" tall manual-save data-cy-user-setup-user-description> + <MkTextarea v-model="description" :max="500" tall manualSave data-cy-user-setup-user-description> <template #label>{{ i18n.ts._profile.description }}</template> </MkTextarea> @@ -37,8 +37,8 @@ import { chooseFileFromPc } from '@/scripts/select-file'; import * as os from '@/os'; import { $i } from '@/account'; -const name = ref(''); -const description = ref(''); +const name = ref($i.name ?? ''); +const description = ref($i.description ?? ''); watch(name, () => { os.apiWithDialog('i/update', { diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index 4e80a5c0fb..566441213e 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -7,10 +7,10 @@ @close="close(true)" @closed="emit('closed')" > - <template v-if="page === 1" #header>{{ i18n.ts._initialAccountSetting.profileSetting }}</template> - <template v-else-if="page === 2" #header>{{ i18n.ts._initialAccountSetting.privacySetting }}</template> - <template v-else-if="page === 3" #header>{{ i18n.ts.follow }}</template> - <template v-else-if="page === 4" #header>{{ i18n.ts.pushNotification }}</template> + <template v-if="page === 1" #header><i class="ti ti-user-edit"></i> {{ i18n.ts._initialAccountSetting.profileSetting }}</template> + <template v-else-if="page === 2" #header><i class="ti ti-lock"></i> {{ i18n.ts._initialAccountSetting.privacySetting }}</template> + <template v-else-if="page === 3" #header><i class="ti ti-user-plus"></i> {{ i18n.ts.follow }}</template> + <template v-else-if="page === 4" #header><i class="ti ti-bell-plus"></i> {{ i18n.ts.pushNotification }}</template> <template v-else-if="page === 5" #header>{{ i18n.ts.done }}</template> <template v-else #header>{{ i18n.ts.initialAccountSetting }}</template> @@ -20,65 +20,80 @@ </div> <Transition mode="out-in" - :enter-active-class="$style.transition_x_enterActive" - :leave-active-class="$style.transition_x_leaveActive" - :enter-from-class="$style.transition_x_enterFrom" - :leave-to-class="$style.transition_x_leaveTo" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" > <template v-if="page === 0"> <div :class="$style.centerPage"> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/> + <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div> <div>{{ i18n.ts._initialAccountSetting.letsStartAccountSetup }}</div> <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton> + <MkButton style="margin: 0 auto;" transparent rounded @click="later(true)">{{ i18n.ts.later }}</MkButton> </div> </MkSpacer> </div> </template> <template v-else-if="page === 1"> <div style="height: 100cqh; overflow: auto;"> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <XProfile/> - <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + <div class="_buttonsCenter" style="margin-top: 16px;"> + <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> + <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> </MkSpacer> </div> </template> <template v-else-if="page === 2"> <div style="height: 100cqh; overflow: auto;"> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <XPrivacy/> - <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + <div class="_buttonsCenter" style="margin-top: 16px;"> + <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> + <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> </MkSpacer> </div> </template> <template v-else-if="page === 3"> <div style="height: 100cqh; overflow: auto;"> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <XFollow/> </MkSpacer> <div :class="$style.pageFooter"> - <MkButton primary rounded gradate style="margin: 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + <div class="_buttonsCenter"> + <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> + <MkButton primary rounded gradate style="" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> </div> </div> </template> <template v-else-if="page === 4"> <div :class="$style.centerPage"> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div> <div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div> - <MkPushNotificationAllowButton primary show-only-to-register style="margin: 0 auto;"/> - <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + <MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/> + <div class="_buttonsCenter" style="margin-top: 16px;"> + <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> + <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> </div> </MkSpacer> </div> </template> <template v-else-if="page === 5"> <div :class="$style.centerPage"> - <MkSpacer :margin-min="20" :margin-max="28"> + <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/> + <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div> @@ -89,7 +104,10 @@ </template> </I18n> <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div> - <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton> + <div class="_buttonsCenter" style="margin-top: 16px;"> + <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> + <MkButton primary rounded gradate data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton> + </div> </div> </MkSpacer> </div> @@ -106,6 +124,7 @@ import MkButton from '@/components/MkButton.vue'; import XProfile from '@/components/MkUserSetupDialog.Profile.vue'; import XFollow from '@/components/MkUserSetupDialog.Follow.vue'; import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue'; +import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; import { host } from '@/config'; @@ -137,6 +156,19 @@ async function close(skip: boolean) { dialog.value.close(); defaultStore.set('accountSetupWizard', -1); } + +async function later(later: boolean) { + if (later) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts._initialAccountSetting.laterAreYouSure, + }); + if (canceled) return; + } + + dialog.value.close(); + defaultStore.set('accountSetupWizard', 0); +} </script> <style lang="scss" module> @@ -183,7 +215,7 @@ async function close(skip: boolean) { left: 0; padding: 12px; border-top: solid 0.5px var(--divider); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + -webkit-backdrop-filter: blur(15px); + backdrop-filter: blur(15px); } </style> diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue index d0f95fceda..0b80c2edc7 100644 --- a/packages/frontend/src/components/MkUsersTooltip.vue +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -1,11 +1,11 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="250" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> <div :class="$style.root"> <div v-for="u in users" :key="u.id" :class="$style.user"> <MkAvatar :class="$style.avatar" :user="u"/> - <MkUserName :class="$style.name" :user="u" :nowrap="true"/> + <MkUserName :user="u" :nowrap="true"/> </div> - <div v-if="users.length < count" :class="$style.omitted">+{{ count - users.length }}</div> + <div v-if="users.length < count">+{{ count - users.length }}</div> </div> </MkTooltip> </template> @@ -43,14 +43,6 @@ const emit = defineEmits<{ } } -.name { - -} - -.omitted { - -} - .avatar { width: 24px; height: 24px; diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index c181d84bc0..c8dbe90944 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -1,5 +1,5 @@ <template> -<MkModal ref="modal" v-slot="{ type }" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 6226768127..9566cc651f 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -39,7 +39,7 @@ <MkTimeline src="local"/> </div> </div> - <div :class="[$style.activeUsersChart, $style.panel]"> + <div :class="$style.panel"> <XActiveUsersChart/> </div> </div> @@ -220,8 +220,4 @@ function exploreOtherServers() { height: 350px; overflow: auto; } - -.activeUsersChart { - -} </style> diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index da98da29d0..1b6ab1f13a 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -1,5 +1,5 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')"> +<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')"> <div :class="[$style.root, { [$style.iconOnly]: (text == null) || success }]"> <i v-if="success" :class="[$style.icon, $style.success]" class="ti ti-check"></i> <MkLoading v-else :class="[$style.icon, $style.waiting]" :em="true"/> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index ad1c02a488..30547c7444 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -1,7 +1,7 @@ <template> <div :class="$style.root"> <template v-if="edit"> - <header :class="$style['edit-header']"> + <header :class="$style.editHeader"> <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> @@ -10,26 +10,26 @@ <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> </header> <Sortable - :model-value="props.widgets" - item-key="id" + :modelValue="props.widgets" + itemKey="id" handle=".handle" :animation="150" :group="{ name: 'SortableMkWidgets' }" - :class="$style['edit-editing']" - @update:model-value="v => emit('updateWidgets', v)" + :class="$style.editEditing" + @update:modelValue="v => emit('updateWidgets', v)" > <template #item="{element}"> - <div :class="[$style.widget, $style['customize-container']]" data-cy-customize-container> - <button :class="$style['customize-container-config']" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> - <button :class="$style['customize-container-remove']" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> + <div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container> + <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> + <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> <div class="handle"> - <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style['customize-container-handle-widget']" :widget="element" @update-props="updateWidget(element.id, $event)"/> + <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style.customizeContainerHandleWidget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> </div> </div> </template> </Sortable> </template> - <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> + <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> </div> </template> @@ -130,7 +130,7 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { } .edit { - &-header { + &Header { margin: 16px 0; > * { @@ -139,17 +139,17 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { } } - &-editing { + &Editing { min-height: 100px; } } -.customize-container { +.customizeContainer { position: relative; cursor: move; - &-config, - &-remove { + &Config, + &Remove { position: absolute; z-index: 10000; top: 8px; @@ -160,17 +160,17 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { border-radius: 4px; } - &-config { + &Config { right: 8px + 8px + 32px; } - &-remove { + &Remove { right: 8px; } - &-handle { + &Handle { - &-widget { + &Widget { pointer-events: none; } } diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index b662479b2a..dafabf2ba8 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -1,11 +1,11 @@ <template> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_window_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_window_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_window_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_window_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" appear - @after-leave="$emit('closed')" + @afterLeave="$emit('closed')" > <div v-if="showing" ref="rootEl" :class="[$style.root, { [$style.maximized]: maximized }]"> <div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown"> diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 4d765fe2f7..0edfd98efc 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -1,5 +1,5 @@ <template> -<MkWindow :initial-width="640" :initial-height="402" :can-resize="true" :close-button="true"> +<MkWindow :initialWidth="640" :initialHeight="402" :canResize="true" :closeButton="true"> <template #header> <i class="icon ti ti-brand-youtube" style="margin-right: 0.5em;"></i> <span>{{ title ?? 'YouTube' }}</span> diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue index a1775c0bdb..22b5edc3c9 100644 --- a/packages/frontend/src/components/form/link.vue +++ b/packages/frontend/src/components/form/link.vue @@ -1,19 +1,19 @@ <template> -<div class="ffcbddfc" :class="{ inline }"> - <a v-if="external" class="main _button" :href="to" target="_blank"> - <span class="icon"><slot name="icon"></slot></span> - <span class="text"><slot></slot></span> - <span class="right"> - <span class="text"><slot name="suffix"></slot></span> - <i class="ti ti-external-link icon"></i> +<div :class="[$style.root, { [$style.inline]: inline }]"> + <a v-if="external" :class="$style.main" class="_button" :href="to" target="_blank"> + <span :class="$style.icon"><slot name="icon"></slot></span> + <span :class="$style.text"><slot></slot></span> + <span :class="$style.suffix"> + <span :class="$style.suffixText"><slot name="suffix"></slot></span> + <i class="ti ti-external-link"></i> </span> </a> - <MkA v-else class="main _button" :class="{ active }" :to="to" :behavior="behavior"> - <span class="icon"><slot name="icon"></slot></span> - <span class="text"><slot></slot></span> - <span class="right"> - <span class="text"><slot name="suffix"></slot></span> - <i class="ti ti-chevron-right icon"></i> + <MkA v-else :class="[$style.main, { [$style.active]: active }]" class="_button" :to="to" :behavior="behavior"> + <span :class="$style.icon"><slot name="icon"></slot></span> + <span :class="$style.text"><slot></slot></span> + <span :class="$style.suffix"> + <span :class="$style.suffixText"><slot name="suffix"></slot></span> + <i class="ti ti-chevron-right"></i> </span> </MkA> </div> @@ -26,70 +26,70 @@ const props = defineProps<{ to: string; active?: boolean; external?: boolean; - behavior?: null | 'window' | 'browser' | 'modalWindow'; + behavior?: null | 'window' | 'browser'; inline?: boolean; }>(); </script> -<style lang="scss" scoped> -.ffcbddfc { +<style lang="scss" module> +.root { display: block; &.inline { display: inline-block; } +} - > .main { - display: flex; - align-items: center; - width: 100%; - box-sizing: border-box; - padding: 10px 14px; - background: var(--buttonBg); - border-radius: 6px; - font-size: 0.9em; +.main { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 14px; + background: var(--buttonBg); + border-radius: 6px; + font-size: 0.9em; - &:hover { - text-decoration: none; - background: var(--buttonHoverBg); - } + &:hover { + text-decoration: none; + background: var(--buttonHoverBg); + } - &.active { - color: var(--accent); - background: var(--buttonHoverBg); - } + &.active { + color: var(--accent); + background: var(--buttonHoverBg); + } +} - > .icon { - margin-right: 0.75em; - flex-shrink: 0; - text-align: center; - color: var(--fgTransparentWeak); +.icon { + margin-right: 0.75em; + flex-shrink: 0; + text-align: center; + color: var(--fgTransparentWeak); - &:empty { - display: none; + &:empty { + display: none; - & + .text { - padding-left: 4px; - } - } + & + .text { + padding-left: 4px; } + } +} - > .text { - flex-shrink: 1; - white-space: normal; - padding-right: 12px; - text-align: center; - } +.text { + flex-shrink: 1; + white-space: normal; + padding-right: 12px; + text-align: center; +} - > .right { - margin-left: auto; - opacity: 0.7; - white-space: nowrap; +.suffix { + margin-left: auto; + opacity: 0.7; + white-space: nowrap; - > .text:not(:empty) { - margin-right: 0.75em; - } - } + > .suffixText:not(:empty) { + margin-right: 0.75em; } } </style> diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue index 79ce8fe51f..809d80620f 100644 --- a/packages/frontend/src/components/form/slot.vue +++ b/packages/frontend/src/components/form/slot.vue @@ -1,10 +1,10 @@ <template> -<div class="adhpbeou"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="content"> +<div> + <div :class="$style.label" @click="focus"><slot name="label"></slot></div> + <div> <slot></slot> </div> - <div class="caption"><slot name="caption"></slot></div> + <div :class="$style.caption"><slot name="caption"></slot></div> </div> </template> @@ -16,26 +16,24 @@ function focus() { } </script> -<style lang="scss" scoped> -.adhpbeou { - > .label { - font-size: 0.85em; - padding: 0 0 8px 0; - user-select: none; +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .caption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: var(--fgTransparentWeak); +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); - &:empty { - display: none; - } + &:empty { + display: none; } } </style> diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 3a44c3da3d..b3d8c22b27 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -1,102 +1,66 @@ <template> -<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="pending"> - <MkLoading/> +<div v-if="pending"> + <MkLoading/> +</div> +<div v-else-if="resolved"> + <slot :result="result"></slot> +</div> +<div v-else> + <div :class="$style.error"> + <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</div> + <MkButton inline style="margin-top: 16px;" @click="retry"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> </div> - <div v-else-if="resolved"> - <slot :result="result"></slot> - </div> - <div v-else> - <div class="wszdbhzo"> - <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</div> - <MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> - </div> - </div> -</Transition> +</div> </template> -<script lang="ts"> -import { defineComponent, PropType, ref, watch } from 'vue'; +<script lang="ts" setup> +import { ref, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - }, - - props: { - p: { - type: Function as PropType<() => Promise<any>>, - required: true, - }, - }, +const props = defineProps<{ + p: () => Promise<any>; +}>(); - setup(props, context) { - const pending = ref(true); - const resolved = ref(false); - const rejected = ref(false); - const result = ref(null); +const pending = ref(true); +const resolved = ref(false); +const rejected = ref(false); +const result = ref(null); - const process = () => { - if (props.p == null) { - return; - } - const promise = props.p(); - pending.value = true; - resolved.value = false; - rejected.value = false; - promise.then((_result) => { - pending.value = false; - resolved.value = true; - result.value = _result; - }); - promise.catch(() => { - pending.value = false; - rejected.value = true; - }); - }; - - watch(() => props.p, () => { - process(); - }, { - immediate: true, - }); - - const retry = () => { - process(); - }; +const process = () => { + if (props.p == null) { + return; + } + const promise = props.p(); + pending.value = true; + resolved.value = false; + rejected.value = false; + promise.then((_result) => { + pending.value = false; + resolved.value = true; + result.value = _result; + }); + promise.catch(() => { + pending.value = false; + rejected.value = true; + }); +}; - return { - pending, - resolved, - rejected, - result, - retry, - defaultStore, - i18n, - }; - }, +watch(() => props.p, () => { + process(); +}, { + immediate: true, }); -</script> -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} +const retry = () => { + process(); +}; +</script> -.wszdbhzo { +<style lang="scss" module> +.error { padding: 16px; text-align: center; - - > .retry { - margin-top: 16px; - } } </style> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 40d134dffb..4e608c6efe 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -15,7 +15,7 @@ import { useRouter } from '@/router'; const props = withDefaults(defineProps<{ to: string; activeClass?: null | string; - behavior?: null | 'window' | 'browser' | 'modalWindow'; + behavior?: null | 'window' | 'browser'; }>(), { activeClass: null, behavior: null, @@ -70,14 +70,6 @@ function openWindow() { os.pageWindow(props.to); } -function modalWindow() { - os.modalPageWindow(props.to); -} - -function popout() { - popout_(props.to); -} - function nav(ev: MouseEvent) { if (props.behavior === 'browser') { location.href = props.to; @@ -87,8 +79,6 @@ function nav(ev: MouseEvent) { if (props.behavior) { if (props.behavior === 'window') { return openWindow(); - } else if (props.behavior === 'modalWindow') { - return modalWindow(); } } diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 59358aef70..f93659f5ed 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -1,5 +1,5 @@ <template> -<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :min-scale="2 / 3"> +<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :minScale="2 / 3"> <span>@{{ user.username }}</span> <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> </MkCondensedLine> diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index aa975600f0..8b25ab1b6a 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -1,6 +1,14 @@ <template> <div v-if="chosen && !shouldHide" :class="$style.root"> - <div v-if="!showMenu" :class="[$style.main, $style['form_' + chosen.place]]"> + <div + v-if="!showMenu" + :class="[$style.main, { + [$style.form_square]: chosen.place === 'square', + [$style.form_horizontal]: chosen.place === 'horizontal', + [$style.form_horizontalBig]: chosen.place === 'horizontal-big', + [$style.form_vertical]: chosen.place === 'vertical', + }]" + > <a :href="chosen.url" target="_blank" :class="$style.link"> <img :src="chosen.imageUrl" :class="$style.img"> <button class="_button" :class="$style.i" @click.prevent.stop="toggleMenu"><i :class="$style.iIcon" class="ti ti-info-circle"></i></button> @@ -122,7 +130,7 @@ function reduceFrequency(): void { } } - &.form_horizontal-big { + &.form_horizontalBig { padding: 8px; > .link, diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 42abdcbdcc..422b35c9dd 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,6 +1,6 @@ <template> <component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> - <img :class="$style.inner" :src="url" decoding="async"/> + <MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <div v-if="user.isCat" :class="[$style.ears]"> <div :class="$style.earLeft"> @@ -24,6 +24,7 @@ <script lang="ts" setup> import { watch } from 'vue'; import * as misskey from 'misskey-js'; +import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; import { getStaticImageUrl } from '@/scripts/media-proxy'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue index 1d46ff1ec9..4b2e8e4750 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.vue +++ b/packages/frontend/src/components/global/MkCondensedLine.vue @@ -13,13 +13,20 @@ interface Props { const contentSymbol = Symbol(); const observer = new ResizeObserver((entries) => { + const results: { + container: HTMLSpanElement; + transform: string; + }[] = []; for (const entry of entries) { const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement; const props: Required<Props> = content[contentSymbol]; const container = content.parentElement as HTMLSpanElement; const contentWidth = content.getBoundingClientRect().width; const containerWidth = container.getBoundingClientRect().width; - container.style.transform = `scaleX(${Math.max(props.minScale, Math.min(1, containerWidth / contentWidth))})`; + results.push({ container, transform: `scaleX(${Math.max(props.minScale, Math.min(1, containerWidth / contentWidth))})` }); + } + for (const result of results) { + result.container.style.transform = result.transform; } }); </script> diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 0cb31ffcba..e8a7f17cc6 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -7,7 +7,7 @@ import { computed } from 'vue'; import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy'; import { defaultStore } from '@/store'; -import { customEmojis } from '@/custom-emojis'; +import { customEmojisMap } from '@/custom-emojis'; const props = defineProps<{ name: string; @@ -26,7 +26,7 @@ const rawUrl = computed(() => { return props.url; } if (isLocal.value) { - return customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null; + return customEmojisMap.get(customEmojiName.value)?.url ?? null; } return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`; }); diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts index f6811b6747..685b3b8b8e 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue'; import { within } from '@storybook/testing-library'; import { expect } from '@storybook/jest'; +import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.ts'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts new file mode 100644 index 0000000000..2a50a34390 --- /dev/null +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -0,0 +1,367 @@ +import { VNode, h } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import MkUrl from '@/components/global/MkUrl.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkMention from '@/components/MkMention.vue'; +import MkEmoji from '@/components/global/MkEmoji.vue'; +import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkGoogle from '@/components/MkGoogle.vue'; +import MkSparkle from '@/components/MkSparkle.vue'; +import MkA from '@/components/global/MkA.vue'; +import { host } from '@/config'; +import { defaultStore } from '@/store'; + +const QUOTE_STYLE = ` +display: block; +margin: 8px; +padding: 6px 0 6px 12px; +color: var(--fg); +border-left: solid 3px var(--fg); +opacity: 0.7; +`.split('\n').join(' '); + +export default function(props: { + text: string; + plain?: boolean; + nowrap?: boolean; + author?: Misskey.entities.UserLite; + i?: Misskey.entities.UserLite; + isNote?: boolean; + emojiUrls?: string[]; + rootScale?: number; +}) { + const isNote = props.isNote !== undefined ? props.isNote : true; + + if (props.text == null || props.text === '') return; + + const ast = (props.plain ? mfm.parseSimple : mfm.parse)(props.text); + + const validTime = (t: string | null | undefined) => { + if (t == null) return null; + return t.match(/^[0-9.]+s$/) ? t : null; + }; + + const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm; + + /** + * Gen Vue Elements from MFM AST + * @param ast MFM AST + * @param scale How times large the text is + */ + const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => { + switch (token.type) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (!props.plain) { + const res: (VNode | string)[] = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children, scale))]; + } + + case 'strike': { + return [h('del', genEl(token.children, scale))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;', + }, genEl(token.children, scale)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style; + switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) ?? '1s'; + style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) ?? '1s'; + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) ?? '1.5s'; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + return h('span', { + class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', + }, genEl(token.children, scale * 2)); + } + case 'x3': { + return h('span', { + class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', + }, genEl(token.children, scale * 3)); + } + case 'x4': { + return h('span', { + class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', + }, genEl(token.children, scale * 4)); + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children, scale)); + } + case 'rainbow': { + const speed = validTime(token.props.args.speed) ?? '1s'; + style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; + break; + } + case 'sparkle': { + if (!useAnim) { + return genEl(token.children, scale); + } + return h(MkSparkle, {}, genEl(token.children, scale)); + } + case 'rotate': { + const degrees = parseFloat(token.props.args.deg ?? '90'); + style = `transform: rotate(${degrees}deg); transform-origin: center center;`; + break; + } + case 'position': { + if (!defaultStore.state.advancedMfm) break; + const x = parseFloat(token.props.args.x ?? '0'); + const y = parseFloat(token.props.args.y ?? '0'); + style = `transform: translateX(${x}em) translateY(${y}em);`; + break; + } + case 'scale': { + if (!defaultStore.state.advancedMfm) { + style = ''; + break; + } + const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); + const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); + style = `transform: scale(${x}, ${y});`; + scale = scale * Math.max(x, y); + break; + } + case 'fg': { + let color = token.props.args.color; + if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; + style = `color: #${color};`; + break; + } + case 'bg': { + let color = token.props.args.color; + if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; + style = `background-color: #${color};`; + break; + } + } + if (style == null) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); + } else { + return h('span', { + style: 'display: inline-block; ' + style, + }, genEl(token.children, scale)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;', + }, genEl(token.children, scale))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;', + }, genEl(token.children, scale))]; + } + + case 'url': { + return [h(MkUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(MkLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children, scale))]; + } + + case 'mention': { + return [h(MkMention, { + key: Math.random(), + host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) || host, + username: token.props.username, + })]; + } + + case 'hashtag': { + return [h(MkA, { + key: Math.random(), + to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);', + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + lang: token.props.lang, + })]; + } + + case 'inlineCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + inline: true, + })]; + } + + case 'quote': { + if (!props.nowrap) { + return [h('div', { + style: QUOTE_STYLE, + }, genEl(token.children, scale))]; + } else { + return [h('span', { + style: QUOTE_STYLE, + }, genEl(token.children, scale))]; + } + } + + case 'emojiCode': { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.author?.host == null) { + return [h(MkCustomEmoji, { + key: Math.random(), + name: token.props.name, + normal: props.plain, + host: null, + useOriginalSize: scale >= 2.5, + })]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) { + return [h('span', `:${token.props.name}:`)]; + } else { + return [h(MkCustomEmoji, { + key: Math.random(), + name: token.props.name, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + url: props.emojiUrls ? props.emojiUrls[token.props.name] : null, + normal: props.plain, + host: props.author.host, + useOriginalSize: scale >= 2.5, + })]; + } + } + } + + case 'unicodeEmoji': { + return [h(MkEmoji, { + key: Math.random(), + emoji: token.props.emoji, + })]; + } + + case 'mathInline': { + return [h('code', token.props.formula)]; + } + + case 'mathBlock': { + return [h('code', token.props.formula)]; + } + + case 'search': { + return [h(MkGoogle, { + key: Math.random(), + q: token.props.query, + })]; + } + + case 'plain': { + return [h('span', genEl(token.children, scale))]; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('unrecognized ast type:', (token as any).type); + + return []; + } + } + }).flat(Infinity) as (VNode | string)[]; + + return h('span', { + // https://codeday.me/jp/qa/20190424/690106.html + style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;', + }, genEl(ast, props.rootScale ?? 1)); +} diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue deleted file mode 100644 index 28a0d1c986..0000000000 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue +++ /dev/null @@ -1,171 +0,0 @@ -<template> -<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :is-note="isNote" :class="[$style.root, { [$style.nowrap]: nowrap }]"/> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import MfmCore from '@/components/mfm'; - -const props = withDefaults(defineProps<{ - text: string; - plain?: boolean; - nowrap?: boolean; - author?: any; - isNote?: boolean; -}>(), { - plain: false, - nowrap: false, - author: null, - isNote: true, -}); -</script> - -<style lang="scss"> -._mfm_blur_ { - filter: blur(6px); - transition: filter 0.3s; - - &:hover { - filter: blur(0px); - } -} - -.mfm-x2 { - --mfm-zoom-size: 200%; -} - -.mfm-x3 { - --mfm-zoom-size: 400%; -} - -.mfm-x4 { - --mfm-zoom-size: 600%; -} - -.mfm-x2, .mfm-x3, .mfm-x4 { - font-size: var(--mfm-zoom-size); - - .mfm-x2, .mfm-x3, .mfm-x4 { - /* only half effective */ - font-size: calc(var(--mfm-zoom-size) / 2 + 50%); - - .mfm-x2, .mfm-x3, .mfm-x4 { - /* disabled */ - font-size: 100%; - } - } -} - -@keyframes mfm-spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@keyframes mfm-spinX { - 0% { transform: perspective(128px) rotateX(0deg); } - 100% { transform: perspective(128px) rotateX(360deg); } -} - -@keyframes mfm-spinY { - 0% { transform: perspective(128px) rotateY(0deg); } - 100% { transform: perspective(128px) rotateY(360deg); } -} - -@keyframes mfm-jump { - 0% { transform: translateY(0); } - 25% { transform: translateY(-16px); } - 50% { transform: translateY(0); } - 75% { transform: translateY(-8px); } - 100% { transform: translateY(0); } -} - -@keyframes mfm-bounce { - 0% { transform: translateY(0) scale(1, 1); } - 25% { transform: translateY(-16px) scale(1, 1); } - 50% { transform: translateY(0) scale(1, 1); } - 75% { transform: translateY(0) scale(1.5, 0.75); } - 100% { transform: translateY(0) scale(1, 1); } -} - -// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; -// let css = ''; -// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } -@keyframes mfm-twitch { - 0% { transform: translate(7px, -2px) } - 5% { transform: translate(-3px, 1px) } - 10% { transform: translate(-7px, -1px) } - 15% { transform: translate(0px, -1px) } - 20% { transform: translate(-8px, 6px) } - 25% { transform: translate(-4px, -3px) } - 30% { transform: translate(-4px, -6px) } - 35% { transform: translate(-8px, -8px) } - 40% { transform: translate(4px, 6px) } - 45% { transform: translate(-3px, 1px) } - 50% { transform: translate(2px, -10px) } - 55% { transform: translate(-7px, 0px) } - 60% { transform: translate(-2px, 4px) } - 65% { transform: translate(3px, -8px) } - 70% { transform: translate(6px, 7px) } - 75% { transform: translate(-7px, -2px) } - 80% { transform: translate(-7px, -8px) } - 85% { transform: translate(9px, 3px) } - 90% { transform: translate(-3px, -2px) } - 95% { transform: translate(-10px, 2px) } - 100% { transform: translate(-2px, -6px) } -} - -// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; -// let css = ''; -// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } -@keyframes mfm-shake { - 0% { transform: translate(-3px, -1px) rotate(-8deg) } - 5% { transform: translate(0px, -1px) rotate(-10deg) } - 10% { transform: translate(1px, -3px) rotate(0deg) } - 15% { transform: translate(1px, 1px) rotate(11deg) } - 20% { transform: translate(-2px, 1px) rotate(1deg) } - 25% { transform: translate(-1px, -2px) rotate(-2deg) } - 30% { transform: translate(-1px, 2px) rotate(-3deg) } - 35% { transform: translate(2px, 1px) rotate(6deg) } - 40% { transform: translate(-2px, -3px) rotate(-9deg) } - 45% { transform: translate(0px, -1px) rotate(-12deg) } - 50% { transform: translate(1px, 2px) rotate(10deg) } - 55% { transform: translate(0px, -3px) rotate(8deg) } - 60% { transform: translate(1px, -1px) rotate(8deg) } - 65% { transform: translate(0px, -1px) rotate(-7deg) } - 70% { transform: translate(-1px, -3px) rotate(6deg) } - 75% { transform: translate(0px, -2px) rotate(4deg) } - 80% { transform: translate(-2px, -1px) rotate(3deg) } - 85% { transform: translate(1px, -3px) rotate(-10deg) } - 90% { transform: translate(1px, 0px) rotate(3deg) } - 95% { transform: translate(-2px, 0px) rotate(-3deg) } - 100% { transform: translate(2px, 1px) rotate(2deg) } -} - -@keyframes mfm-rubberBand { - from { transform: scale3d(1, 1, 1); } - 30% { transform: scale3d(1.25, 0.75, 1); } - 40% { transform: scale3d(0.75, 1.25, 1); } - 50% { transform: scale3d(1.15, 0.85, 1); } - 65% { transform: scale3d(0.95, 1.05, 1); } - 75% { transform: scale3d(1.05, 0.95, 1); } - to { transform: scale3d(1, 1, 1); } -} - -@keyframes mfm-rainbow { - 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } - 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } -} -</style> - -<style lang="scss" module> -.root { - white-space: pre-wrap; - - &.nowrap { - white-space: pre; - word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html - overflow: hidden; - text-overflow: ellipsis; - } -} -</style> diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 9e1da64e61..d71343baf9 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -15,8 +15,8 @@ {{ t.title }} </div> <Transition - v-else mode="in-out" @enter="enter" @after-enter="afterEnter" @leave="leave" - @after-leave="afterLeave" + v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave" + @afterLeave="afterLeave" > <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> </Transition> diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index b91d378b17..0a21d39bca 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -21,7 +21,7 @@ </div> </div> </div> - <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :root-el="el" @update:tab="key => emit('update:tab', key)" @tab-click="onTabClick"/> + <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/> </template> <div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight"> <template v-for="action in actions"> @@ -30,7 +30,7 @@ </div> </div> <div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> - <XTabs :class="$style.tabs" :tab="tab" :tabs="tabs" :root-el="el" @update:tab="key => emit('update:tab', key)" @tab-click="onTabClick"/> + <XTabs :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/> </div> </div> </template> diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 44c02088da..e5dba54b4e 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -14,6 +14,7 @@ <script lang="ts" setup> import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue'; +import { $$ } from 'vue/macros'; import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const'; const rootEl = $shallowRef<HTMLElement>(); @@ -83,8 +84,8 @@ onMounted(() => { onUnmounted(() => { observer.disconnect(); }); -</script> - -<style lang="scss" module> -</style> +defineExpose({ + rootEl: $$(rootEl), +}); +</script> diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 261cc0ee18..dfc3c89798 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -58,7 +58,6 @@ function tick() { if (props.mode === 'relative' || props.mode === 'detail') { tick(); - onUnmounted(() => { window.clearTimeout(tickId); }); diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 2a92780306..c1efd9a06b 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -6,7 +6,7 @@ <template v-if="!self"> <span :class="$style.schema">{{ schema }}//</span> <span :class="$style.hostname">{{ hostname }}</span> - <span v-if="port != ''" :class="$style.port">:{{ port }}</span> + <span v-if="port != ''">:{{ port }}</span> </template> <template v-if="pathname === '/' && self"> <span :class="$style.self">{{ hostname }}</span> diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue index 4186a4a4fb..c9e85c5460 100644 --- a/packages/frontend/src/components/global/MkUserName.vue +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -1,5 +1,5 @@ <template> -<Mfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emoji-urls="user.emojis"/> +<Mfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emojiUrls="user.emojis"/> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts index 1fd293ba10..2708b759aa 100644 --- a/packages/frontend/src/components/global/i18n.ts +++ b/packages/frontend/src/components/global/i18n.ts @@ -1,42 +1,24 @@ -import { h, defineComponent } from 'vue'; +import { h } from 'vue'; -export default defineComponent({ - props: { - src: { - type: String, - required: true, - }, - tag: { - type: String, - required: false, - default: 'span', - }, - textTag: { - type: String, - required: false, - default: null, - }, - }, - render() { - let str = this.src; - const parsed = [] as (string | { arg: string; })[]; - while (true) { - const nextBracketOpen = str.indexOf('{'); - const nextBracketClose = str.indexOf('}'); +export default function(props: { src: string; tag?: string; textTag?: string; }, { slots }) { + let str = props.src; + const parsed = [] as (string | { arg: string; })[]; + while (true) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); - if (nextBracketOpen === -1) { - parsed.push(str); - break; - } else { - if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); - parsed.push({ - arg: str.substring(nextBracketOpen + 1, nextBracketClose), - }); - } - - str = str.substr(nextBracketClose + 1); + if (nextBracketOpen === -1) { + parsed.push(str); + break; + } else { + if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + parsed.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose), + }); } - return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); - }, -}); + str = str.substr(nextBracketClose + 1); + } + + return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); +} diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 4ef8111da9..ee2a2bc7bd 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -1,6 +1,6 @@ import { App } from 'vue'; -import Mfm from './global/MkMisskeyFlavoredMarkdown.vue'; +import Mfm from './global/MkMisskeyFlavoredMarkdown.ts'; import MkA from './global/MkA.vue'; import MkAcct from './global/MkAcct.vue'; import MkAvatar from './global/MkAvatar.vue'; diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts deleted file mode 100644 index c3c07b5834..0000000000 --- a/packages/frontend/src/components/mfm.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { VNode, defineComponent, h } from 'vue'; -import * as mfm from 'mfm-js'; -import MkUrl from '@/components/global/MkUrl.vue'; -import MkLink from '@/components/MkLink.vue'; -import MkMention from '@/components/MkMention.vue'; -import MkEmoji from '@/components/global/MkEmoji.vue'; -import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; -import MkCode from '@/components/MkCode.vue'; -import MkGoogle from '@/components/MkGoogle.vue'; -import MkSparkle from '@/components/MkSparkle.vue'; -import MkA from '@/components/global/MkA.vue'; -import { host } from '@/config'; -import { defaultStore } from '@/store'; - -const QUOTE_STYLE = ` -display: block; -margin: 8px; -padding: 6px 0 6px 12px; -color: var(--fg); -border-left: solid 3px var(--fg); -opacity: 0.7; -`.split('\n').join(' '); - -export default defineComponent({ - props: { - text: { - type: String, - required: true, - }, - plain: { - type: Boolean, - default: false, - }, - nowrap: { - type: Boolean, - default: false, - }, - author: { - type: Object, - default: null, - }, - i: { - type: Object, - default: null, - }, - isNote: { - type: Boolean, - default: true, - }, - emojiUrls: { - type: Object, - default: null, - }, - rootScale: { - type: Number, - default: 1, - } - }, - - render() { - if (this.text == null || this.text === '') return; - - const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text); - - const validTime = (t: string | null | undefined) => { - if (t == null) return null; - return t.match(/^[0-9.]+s$/) ? t : null; - }; - - const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm; - - /** - * Gen Vue Elements from MFM AST - * @param ast MFM AST - * @param scale How times large the text is - */ - const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => { - switch (token.type) { - case 'text': { - const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); - - if (!this.plain) { - const res: (VNode | string)[] = []; - for (const t of text.split('\n')) { - res.push(h('br')); - res.push(t); - } - res.shift(); - return res; - } else { - return [text.replace(/\n/g, ' ')]; - } - } - - case 'bold': { - return [h('b', genEl(token.children, scale))]; - } - - case 'strike': { - return [h('del', genEl(token.children, scale))]; - } - - case 'italic': { - return h('i', { - style: 'font-style: oblique;', - }, genEl(token.children, scale)); - } - - case 'fn': { - // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる - let style; - switch (token.props.name) { - case 'tada': { - const speed = validTime(token.props.args.speed) ?? '1s'; - style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : ''); - break; - } - case 'jelly': { - const speed = validTime(token.props.args.speed) ?? '1s'; - style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); - break; - } - case 'twitch': { - const speed = validTime(token.props.args.speed) ?? '0.5s'; - style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : ''; - break; - } - case 'shake': { - const speed = validTime(token.props.args.speed) ?? '0.5s'; - style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : ''; - break; - } - case 'spin': { - const direction = - token.props.args.left ? 'reverse' : - token.props.args.alternate ? 'alternate' : - 'normal'; - const anime = - token.props.args.x ? 'mfm-spinX' : - token.props.args.y ? 'mfm-spinY' : - 'mfm-spin'; - const speed = validTime(token.props.args.speed) ?? '1.5s'; - style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; - break; - } - case 'jump': { - const speed = validTime(token.props.args.speed) ?? '0.75s'; - style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : ''; - break; - } - case 'bounce': { - const speed = validTime(token.props.args.speed) ?? '0.75s'; - style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; - break; - } - case 'flip': { - const transform = - (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : - token.props.args.v ? 'scaleY(-1)' : - 'scaleX(-1)'; - style = `transform: ${transform};`; - break; - } - case 'x2': { - return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', - }, genEl(token.children, scale * 2)); - } - case 'x3': { - return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', - }, genEl(token.children, scale * 3)); - } - case 'x4': { - return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', - }, genEl(token.children, scale * 4)); - } - case 'font': { - const family = - token.props.args.serif ? 'serif' : - token.props.args.monospace ? 'monospace' : - token.props.args.cursive ? 'cursive' : - token.props.args.fantasy ? 'fantasy' : - token.props.args.emoji ? 'emoji' : - token.props.args.math ? 'math' : - null; - if (family) style = `font-family: ${family};`; - break; - } - case 'blur': { - return h('span', { - class: '_mfm_blur_', - }, genEl(token.children, scale)); - } - case 'rainbow': { - const speed = validTime(token.props.args.speed) ?? '1s'; - style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; - break; - } - case 'sparkle': { - if (!useAnim) { - return genEl(token.children, scale); - } - return h(MkSparkle, {}, genEl(token.children, scale)); - } - case 'rotate': { - const degrees = parseFloat(token.props.args.deg ?? '90'); - style = `transform: rotate(${degrees}deg); transform-origin: center center;`; - break; - } - case 'position': { - if (!defaultStore.state.advancedMfm) break; - const x = parseFloat(token.props.args.x ?? '0'); - const y = parseFloat(token.props.args.y ?? '0'); - style = `transform: translateX(${x}em) translateY(${y}em);`; - break; - } - case 'scale': { - if (!defaultStore.state.advancedMfm) { - style = ''; - break; - } - const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); - const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); - style = `transform: scale(${x}, ${y});`; - scale = scale * Math.max(x, y); - break; - } - case 'fg': { - let color = token.props.args.color; - if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; - style = `color: #${color};`; - break; - } - case 'bg': { - let color = token.props.args.color; - if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; - style = `background-color: #${color};`; - break; - } - } - if (style == null) { - return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); - } else { - return h('span', { - style: 'display: inline-block; ' + style, - }, genEl(token.children, scale)); - } - } - - case 'small': { - return [h('small', { - style: 'opacity: 0.7;', - }, genEl(token.children, scale))]; - } - - case 'center': { - return [h('div', { - style: 'text-align:center;', - }, genEl(token.children, scale))]; - } - - case 'url': { - return [h(MkUrl, { - key: Math.random(), - url: token.props.url, - rel: 'nofollow noopener', - })]; - } - - case 'link': { - return [h(MkLink, { - key: Math.random(), - url: token.props.url, - rel: 'nofollow noopener', - }, genEl(token.children, scale))]; - } - - case 'mention': { - return [h(MkMention, { - key: Math.random(), - host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, - username: token.props.username, - })]; - } - - case 'hashtag': { - return [h(MkA, { - key: Math.random(), - to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, - style: 'color:var(--hashtag);', - }, `#${token.props.hashtag}`)]; - } - - case 'blockCode': { - return [h(MkCode, { - key: Math.random(), - code: token.props.code, - lang: token.props.lang, - })]; - } - - case 'inlineCode': { - return [h(MkCode, { - key: Math.random(), - code: token.props.code, - inline: true, - })]; - } - - case 'quote': { - if (!this.nowrap) { - return [h('div', { - style: QUOTE_STYLE, - }, genEl(token.children, scale))]; - } else { - return [h('span', { - style: QUOTE_STYLE, - }, genEl(token.children, scale))]; - } - } - - case 'emojiCode': { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.author?.host == null) { - return [h(MkCustomEmoji, { - key: Math.random(), - name: token.props.name, - normal: this.plain, - host: null, - useOriginalSize: scale >= 2.5, - })]; - } else { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.emojiUrls && (this.emojiUrls[token.props.name] == null)) { - return [h('span', `:${token.props.name}:`)]; - } else { - return [h(MkCustomEmoji, { - key: Math.random(), - name: token.props.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - url: this.emojiUrls ? this.emojiUrls[token.props.name] : null, - normal: this.plain, - host: this.author.host, - useOriginalSize: scale >= 2.5, - })]; - } - } - } - - case 'unicodeEmoji': { - return [h(MkEmoji, { - key: Math.random(), - emoji: token.props.emoji, - })]; - } - - case 'mathInline': { - return [h('code', token.props.formula)]; - } - - case 'mathBlock': { - return [h('code', token.props.formula)]; - } - - case 'search': { - return [h(MkGoogle, { - key: Math.random(), - q: token.props.query, - })]; - } - - case 'plain': { - return [h('span', genEl(token.children, scale))]; - } - - default: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - console.error('unrecognized ast type:', (token as any).type); - - return []; - } - } - }).flat(Infinity) as (VNode | string)[]; - - // Parse ast to DOM - return h('span', genEl(ast, this.rootScale)); - }, -}); diff --git a/packages/frontend/src/components/page/block.type.ts b/packages/frontend/src/components/page/block.type.ts new file mode 100644 index 0000000000..71249a8aff --- /dev/null +++ b/packages/frontend/src/components/page/block.type.ts @@ -0,0 +1,29 @@ +export type BlockBase = { + id: string; + type: string; +}; + +export type TextBlock = BlockBase & { + type: 'text'; + text: string; +}; + +export type SectionBlock = BlockBase & { + type: 'section'; + title: string; + children: Block[]; +}; + +export type ImageBlock = BlockBase & { + type: 'image'; + fileId: string | null; +}; + +export type NoteBlock = BlockBase & { + type: 'note'; + detailed: boolean; + note: string | null; +}; + +export type Block = + TextBlock | SectionBlock | ImageBlock | NoteBlock; diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue index f3e7764604..2bf3d12daa 100644 --- a/packages/frontend/src/components/page/page.block.vue +++ b/packages/frontend/src/components/page/page.block.vue @@ -1,44 +1,29 @@ <template> -<component :is="'x-' + block.type" :key="block.id" :block="block" :hpml="hpml" :h="h"/> +<component :is="getComponent(block.type)" :key="block.id" :page="page" :block="block" :h="h"/> </template> -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; import XText from './page.text.vue'; import XSection from './page.section.vue'; import XImage from './page.image.vue'; -import XButton from './page.button.vue'; -import XNumberInput from './page.number-input.vue'; -import XTextInput from './page.text-input.vue'; -import XTextareaInput from './page.textarea-input.vue'; -import XSwitch from './page.switch.vue'; -import XIf from './page.if.vue'; -import XTextarea from './page.textarea.vue'; -import XPost from './page.post.vue'; -import XCounter from './page.counter.vue'; -import XRadioButton from './page.radio-button.vue'; -import XCanvas from './page.canvas.vue'; import XNote from './page.note.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { Block } from '@/scripts/hpml/block'; +import { Block } from './block.type'; -export default defineComponent({ - components: { - XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote, - }, - props: { - block: { - type: Object as PropType<Block>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - h: { - type: Number, - required: true, - }, - }, -}); +function getComponent(type: string) { + switch (type) { + case 'text': return XText; + case 'section': return XSection; + case 'image': return XImage; + case 'note': return XNote; + default: return null; + } +} + +defineProps<{ + block: Block, + h: number, + page: Misskey.entities.Page, +}>(); </script> diff --git a/packages/frontend/src/components/page/page.button.vue b/packages/frontend/src/components/page/page.button.vue deleted file mode 100644 index 83931021d8..0000000000 --- a/packages/frontend/src/components/page/page.button.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> -<div> - <MkButton class="kudkigyw" :primary="block.primary" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, PropType, unref } from 'vue'; -import MkButton from '../MkButton.vue'; -import * as os from '@/os'; -import { ButtonBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; - -export default defineComponent({ - components: { - MkButton, - }, - props: { - block: { - type: Object as PropType<ButtonBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - methods: { - click() { - if (this.block.action === 'dialog') { - this.hpml.eval(); - os.alert({ - text: this.hpml.interpolate(this.block.content), - }); - } else if (this.block.action === 'resetRandom') { - this.hpml.updateRandomSeed(Math.random()); - this.hpml.eval(); - } else if (this.block.action === 'pushEvent') { - os.api('page-push', { - pageId: this.hpml.page.id, - event: this.block.event, - ...(this.block.var ? { - var: unref(this.hpml.vars)[this.block.var], - } : {}), - }); - - os.alert({ - type: 'success', - text: this.hpml.interpolate(this.block.message), - }); - } else if (this.block.action === 'callAiScript') { - this.hpml.callAiScript(this.block.fn); - } - }, - }, -}); -</script> - -<style lang="scss" scoped> -.kudkigyw { - display: inline-block; - min-width: 200px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/packages/frontend/src/components/page/page.canvas.vue b/packages/frontend/src/components/page/page.canvas.vue deleted file mode 100644 index 82ff36ec36..0000000000 --- a/packages/frontend/src/components/page/page.canvas.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<div class="ysrxegms"> - <canvas ref="canvas" :width="block.width" :height="block.height"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; -import { CanvasBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; - -export default defineComponent({ - props: { - block: { - type: Object as PropType<CanvasBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const canvas: Ref<any> = ref(null); - - onMounted(() => { - props.hpml.registerCanvas(props.block.name, canvas.value); - }); - - return { - canvas, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.ysrxegms { - display: inline-block; - vertical-align: bottom; - overflow: auto; - max-width: 100%; - - > canvas { - display: block; - } -} -</style> diff --git a/packages/frontend/src/components/page/page.counter.vue b/packages/frontend/src/components/page/page.counter.vue deleted file mode 100644 index 63fde6a120..0000000000 --- a/packages/frontend/src/components/page/page.counter.vue +++ /dev/null @@ -1,51 +0,0 @@ -<template> -<div> - <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkButton from '../MkButton.vue'; -import { CounterVarBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; - -export default defineComponent({ - components: { - MkButton, - }, - props: { - block: { - type: Object as PropType<CounterVarBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function click() { - props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1)); - props.hpml.eval(); - } - - return { - click, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.llumlmnx { - display: inline-block; - min-width: 300px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/packages/frontend/src/components/page/page.if.vue b/packages/frontend/src/components/page/page.if.vue deleted file mode 100644 index 372a15f0c6..0000000000 --- a/packages/frontend/src/components/page/page.if.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<div v-show="hpml.vars.value[block.var]"> - <XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h"/> -</div> -</template> - -<script lang="ts"> -import { IfBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { defineComponent, defineAsyncComponent, PropType } from 'vue'; - -export default defineComponent({ - components: { - XBlock: defineAsyncComponent(() => import('./page.block.vue')), - }, - props: { - block: { - type: Object as PropType<IfBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - h: { - type: Number, - required: true, - }, - }, -}); -</script> diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index 6ea81d257f..2edcfb8b1a 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -5,15 +5,15 @@ </template> <script lang="ts" setup> -import { PropType } from 'vue'; +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import { ImageBlock } from './block.type'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { ImageBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; const props = defineProps<{ - block: PropType<ImageBlock>, - hpml: PropType<Hpml>, + block: ImageBlock, + page: Misskey.entities.Page, }>(); -const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId); +const image = props.page.attachedFiles.find(x => x.id === props.block.fileId); </script> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 8c65dabf08..7133a7f5a1 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -1,47 +1,29 @@ <template> -<div class="voxdxuby"> +<div style="margin: 1em 0;"> <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +<script lang="ts" setup> +import { onMounted, Ref, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { NoteBlock } from './block.type'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import * as os from '@/os'; -import { NoteBlock } from '@/scripts/hpml/block'; -export default defineComponent({ - components: { - MkNote, - MkNoteDetailed, - }, - props: { - block: { - type: Object as PropType<NoteBlock>, - required: true, - }, - }, - setup(props, ctx) { - const note: Ref<Record<string, any> | null> = ref(null); +const props = defineProps<{ + block: NoteBlock, + page: Misskey.entities.Page, +}>(); - onMounted(() => { - os.api('notes/show', { noteId: props.block.note }) - .then(result => { - note.value = result; - }); - }); +const note: Ref<Misskey.entities.Note | null> = ref(null); - return { - note, - }; - }, +onMounted(() => { + os.api('notes/show', { noteId: props.block.note }) + .then(result => { + note.value = result; + }); }); </script> - -<style lang="scss" scoped> -.voxdxuby { - margin: 1em 0; -} -</style> diff --git a/packages/frontend/src/components/page/page.number-input.vue b/packages/frontend/src/components/page/page.number-input.vue deleted file mode 100644 index 72c1b6deb0..0000000000 --- a/packages/frontend/src/components/page/page.number-input.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> -<div> - <MkInput class="kudkigyw" :model-value="value" type="number" @update:model-value="updateValue($event)"> - <template #label>{{ hpml.interpolate(block.text) }}</template> - </MkInput> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkInput from '../MkInput.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { NumberInputVarBlock } from '@/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkInput, - }, - props: { - block: { - type: Object as PropType<NumberInputVarBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.kudkigyw { - display: inline-block; - min-width: 300px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/packages/frontend/src/components/page/page.post.vue b/packages/frontend/src/components/page/page.post.vue deleted file mode 100644 index 55da610cb6..0000000000 --- a/packages/frontend/src/components/page/page.post.vue +++ /dev/null @@ -1,111 +0,0 @@ -<template> -<div class="ngbfujlo"> - <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea> - <MkButton class="button" primary :disabled="posting || posted" @click="post()"> - <i v-if="posted" class="ti ti-check"></i> - <i v-else class="ti ti-send"></i> - </MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import MkTextarea from '../MkTextarea.vue'; -import MkButton from '../MkButton.vue'; -import { apiUrl } from '@/config'; -import * as os from '@/os'; -import { PostBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { defaultStore } from '@/store'; -import { $i } from '@/account'; - -export default defineComponent({ - components: { - MkTextarea, - MkButton, - }, - props: { - block: { - type: Object as PropType<PostBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - data() { - return { - text: this.hpml.interpolate(this.block.text), - posted: false, - posting: false, - }; - }, - watch: { - 'hpml.vars': { - handler() { - this.text = this.hpml.interpolate(this.block.text); - }, - deep: true, - }, - }, - methods: { - upload() { - const promise = new Promise((ok) => { - const canvas = this.hpml.canvases[this.block.canvasId]; - canvas.toBlob(blob => { - const formData = new FormData(); - formData.append('file', blob); - formData.append('i', $i.token); - if (defaultStore.state.uploadFolder) { - formData.append('folderId', defaultStore.state.uploadFolder); - } - - window.fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: formData, - }) - .then(response => response.json()) - .then(f => { - ok(f); - }); - }); - }); - os.promiseDialog(promise); - return promise; - }, - async post() { - this.posting = true; - const file = this.block.attachCanvasImage ? await this.upload() : null; - os.apiWithDialog('notes/create', { - text: this.text === '' ? null : this.text, - fileIds: file ? [file.id] : undefined, - }).then(() => { - this.posted = true; - }); - }, - }, -}); -</script> - -<style lang="scss" scoped> -.ngbfujlo { - position: relative; - padding: 32px; - border-radius: 6px; - box-shadow: 0 2px 8px var(--shadow); - z-index: 1; - - > .button { - margin-top: 32px; - } - - @media (max-width: 600px) { - padding: 16px; - - > .button { - margin-top: 16px; - } - } -} -</style> diff --git a/packages/frontend/src/components/page/page.radio-button.vue b/packages/frontend/src/components/page/page.radio-button.vue deleted file mode 100644 index ce8f252e44..0000000000 --- a/packages/frontend/src/components/page/page.radio-button.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> -<div> - <div>{{ hpml.interpolate(block.title) }}</div> - <MkRadio v-for="item in block.values" :key="item" :modelValue="value" :value="item" @update:model-value="updateValue($event)">{{ item }}</MkRadio> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkRadio from '../MkRadio.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { RadioButtonVarBlock } from '@/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkRadio, - }, - props: { - block: { - type: Object as PropType<RadioButtonVarBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue: string) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue, - }; - }, -}); -</script> diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue index 50181b3905..83a16ae0a5 100644 --- a/packages/frontend/src/components/page/page.section.vue +++ b/packages/frontend/src/components/page/page.section.vue @@ -1,59 +1,49 @@ <template> -<section class="sdgxphyu"> - <component :is="'h' + h">{{ block.title }}</component> +<section> + <component + :is="'h' + h" + :class="{ + 'h2': h === 2, + 'h3': h === 3, + 'h4': h === 4, + }" + > + {{ block.title }} + </component> - <div class="children"> - <XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h + 1"/> + <div class="_gaps"> + <XBlock v-for="child in block.children" :key="child.id" :page="page" :block="child" :h="h + 1"/> </div> </section> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent, PropType } from 'vue'; -import { SectionBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import * as Misskey from 'misskey-js'; +import { SectionBlock } from './block.type'; -export default defineComponent({ - components: { - XBlock: defineAsyncComponent(() => import('./page.block.vue')), - }, - props: { - block: { - type: Object as PropType<SectionBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - h: { - required: true, - }, - }, -}); -</script> - -<style lang="scss" scoped> -.sdgxphyu { - margin: 1.5em 0; +const XBlock = defineAsyncComponent(() => import('./page.block.vue')); - > h2 { - font-size: 1.35em; - margin: 0 0 0.5em 0; - } +defineProps<{ + block: SectionBlock, + h: number, + page: Misskey.entities.Page, +}>(); +</script> - > h3 { - font-size: 1em; - margin: 0 0 0.5em 0; - } +<style lang="scss" module> +.h2 { + font-size: 1.35em; + margin: 0 0 0.5em 0; +} - > h4 { - font-size: 1em; - margin: 0 0 0.5em 0; - } +.h3 { + font-size: 1em; + margin: 0 0 0.5em 0; +} - > .children { - //padding 16px - } +.h4 { + font-size: 1em; + margin: 0 0 0.5em 0; } </style> diff --git a/packages/frontend/src/components/page/page.switch.vue b/packages/frontend/src/components/page/page.switch.vue deleted file mode 100644 index b5f3464512..0000000000 --- a/packages/frontend/src/components/page/page.switch.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> -<div class="hkcxmtwj"> - <MkSwitch :model-value="value" @update:model-value="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkSwitch from '../MkSwitch.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { SwitchVarBlock } from '@/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkSwitch, - }, - props: { - block: { - type: Object as PropType<SwitchVarBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue: boolean) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.hkcxmtwj { - display: inline-block; - margin: 16px auto; - - & + .hkcxmtwj { - margin-left: 16px; - } -} -</style> diff --git a/packages/frontend/src/components/page/page.text-input.vue b/packages/frontend/src/components/page/page.text-input.vue deleted file mode 100644 index d020a99de8..0000000000 --- a/packages/frontend/src/components/page/page.text-input.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> -<div> - <MkInput class="kudkigyw" :model-value="value" type="text" @update:model-value="updateValue($event)"> - <template #label>{{ hpml.interpolate(block.text) }}</template> - </MkInput> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkInput from '../MkInput.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { TextInputVarBlock } from '@/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkInput, - }, - props: { - block: { - type: Object as PropType<TextInputVarBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.kudkigyw { - display: inline-block; - min-width: 300px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index e0e4959efa..48ce4b0e1e 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -1,70 +1,24 @@ <template> -<div class="mrdgzndn"> - <Mfm :key="text" :text="text" :is-note="false" :i="$i"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" class="url"/> +<div class="_gaps"> + <Mfm :text="block.text" :isNote="false" :i="$i"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> </div> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent, PropType } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; import * as mfm from 'mfm-js'; -import { TextBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; +import * as Misskey from 'misskey-js'; +import { TextBlock } from './block.type'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { $i } from '@/account'; -export default defineComponent({ - components: { - MkUrlPreview: defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')), - }, - props: { - block: { - type: Object as PropType<TextBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - data() { - return { - text: this.hpml.interpolate(this.block.text), - $i, - }; - }, - computed: { - urls(): string[] { - if (this.text) { - return extractUrlFromMfm(mfm.parse(this.text)); - } else { - return []; - } - }, - }, - watch: { - 'hpml.vars': { - handler() { - this.text = this.hpml.interpolate(this.block.text); - }, - deep: true, - }, - }, -}); -</script> - -<style lang="scss" scoped> -.mrdgzndn { - &:not(:first-child) { - margin-top: 0.5em; - } +const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); - &:not(:last-child) { - margin-bottom: 0.5em; - } +const props = defineProps<{ + block: TextBlock, + page: Misskey.entities.Page, +}>(); - > .url { - margin: 0.5em 0; - } -} -</style> +const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : []; +</script> diff --git a/packages/frontend/src/components/page/page.textarea-input.vue b/packages/frontend/src/components/page/page.textarea-input.vue deleted file mode 100644 index db3a96dd1b..0000000000 --- a/packages/frontend/src/components/page/page.textarea-input.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> -<div> - <MkTextarea :model-value="value" @update:model-value="updateValue($event)"> - <template #label>{{ hpml.interpolate(block.text) }}</template> - </MkTextarea> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkTextarea from '../MkTextarea.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { TextInputVarBlock } from '@/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkTextarea, - }, - props: { - block: { - type: Object as PropType<TextInputVarBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue, - }; - }, -}); -</script> diff --git a/packages/frontend/src/components/page/page.textarea.vue b/packages/frontend/src/components/page/page.textarea.vue deleted file mode 100644 index 9b82412e8a..0000000000 --- a/packages/frontend/src/components/page/page.textarea.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<MkTextarea :model-value="text" readonly></MkTextarea> -</template> - -<script lang="ts"> -import { TextBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { defineComponent, PropType } from 'vue'; -import MkTextarea from '../MkTextarea.vue'; - -export default defineComponent({ - components: { - MkTextarea, - }, - props: { - block: { - type: Object as PropType<TextBlock>, - required: true, - }, - hpml: { - type: Object as PropType<Hpml>, - required: true, - }, - }, - data() { - return { - text: this.hpml.interpolate(this.block.text), - }; - }, - watch: { - 'hpml.vars': { - handler() { - this.text = this.hpml.interpolate(this.block.text); - }, - deep: true, - }, - }, -}); -</script> diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index 5f1f62581e..c2c2693224 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -1,56 +1,25 @@ <template> -<div v-if="hpml" class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }"> - <XBlock v-for="child in page.content" :key="child.id" :block="child" :hpml="hpml" :h="2"/> +<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }"> + <XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, nextTick, PropType } from 'vue'; +<script lang="ts" setup> +import { onMounted, nextTick } from 'vue'; +import * as Misskey from 'misskey-js'; import XBlock from './page.block.vue'; -import { Hpml } from '@/scripts/hpml/evaluator'; -import { url } from '@/config'; -import { $i } from '@/account'; -export default defineComponent({ - components: { - XBlock, - }, - props: { - page: { - type: Object as PropType<Record<string, any>>, - required: true, - }, - }, - setup(props, ctx) { - const hpml = new Hpml(props.page, { - randomSeed: Math.random(), - visitor: $i, - url: url, - }); - - onMounted(() => { - nextTick(() => { - hpml.eval(); - }); - }); - - return { - hpml, - }; - }, -}); +defineProps<{ + page: Misskey.entities.Page, +}>(); </script> -<style lang="scss" scoped> -.iroscrza { - &.serif { - > div { - font-family: serif; - } - } +<style lang="scss" module> +.serif { + font-family: serif; +} - &.center { - text-align: center; - } +.center { + text-align: center; } </style> diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index a89a420d77..9b738b2fd4 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -1,8 +1,7 @@ -import { shallowRef, computed, markRaw } from 'vue'; +import { shallowRef, computed, markRaw, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { api, apiGet } from './os'; -import { miLocalStorage } from './local-storage'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { get, set } from '@/scripts/idb-proxy'; const storageCache = await get('emojis'); @@ -17,6 +16,17 @@ export const customEmojiCategories = computed<[ ...string[], null ]>(() => { return markRaw([...Array.from(categories), null]); }); +export const customEmojisMap = new Map<string, Misskey.entities.CustomEmoji>(); +watch(customEmojis, emojis => { + customEmojisMap.clear(); + for (const emoji of emojis) { + customEmojisMap.set(emoji.name, emoji); + } +}, { immediate: true }); + +// TODO: ここら辺副作用なのでいい感じにする +const stream = useStream(); + stream.on('emojiAdded', emojiData => { customEmojis.value = [emojiData.emoji, ...customEmojis.value]; set('emojis', customEmojis.value); @@ -34,10 +44,9 @@ stream.on('emojiDeleted', emojiData => { export async function fetchCustomEmojis(force = false) { const now = Date.now(); - const needsMigration = miLocalStorage.getItem('emojis') != null; let res; - if (force || needsMigration) { + if (force) { res = await api('emojis', {}); } else { const lastFetchedAt = await get('lastEmojisFetchedAt'); @@ -48,10 +57,6 @@ export async function fetchCustomEmojis(force = false) { customEmojis.value = res.emojis; set('emojis', res.emojis); set('lastEmojisFetchedAt', now); - if (needsMigration) { - miLocalStorage.removeItem('emojis'); - miLocalStorage.removeItem('lastEmojisFetchedAt'); - } } let cachedTags; diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts index 5d13497b5f..373141fa35 100644 --- a/packages/frontend/src/directives/tooltip.ts +++ b/packages/frontend/src/directives/tooltip.ts @@ -5,7 +5,7 @@ import { defineAsyncComponent, Directive, ref } from 'vue'; import { isTouchUsing } from '@/scripts/touch'; import { popup, alert } from '@/os'; -const start = isTouchUsing ? 'touchstart' : 'mouseover'; +const start = isTouchUsing ? 'touchstart' : 'mouseenter'; const end = isTouchUsing ? 'touchend' : 'mouseleave'; export default { @@ -63,16 +63,24 @@ export default { ev.preventDefault(); }); - el.addEventListener(start, () => { + el.addEventListener(start, (ev) => { window.clearTimeout(self.showTimer); window.clearTimeout(self.hideTimer); - self.showTimer = window.setTimeout(self.show, delay); + if (delay === 0) { + self.show(); + } else { + self.showTimer = window.setTimeout(self.show, delay); + } }, { passive: true }); el.addEventListener(end, () => { window.clearTimeout(self.showTimer); window.clearTimeout(self.hideTimer); - self.hideTimer = window.setTimeout(self.close, delay); + if (delay === 0) { + self.close(); + } else { + self.hideTimer = window.setTimeout(self.close, delay); + } }, { passive: true }); el.addEventListener('click', () => { diff --git a/packages/frontend/src/emojilist.json b/packages/frontend/src/emojilist.json index 402e82e33b..fde06a4aa0 100644 --- a/packages/frontend/src/emojilist.json +++ b/packages/frontend/src/emojilist.json @@ -1,1785 +1,1784 @@ [ - { "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] }, - { "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] }, - { "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] }, - { "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] }, - { "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] }, - { "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] }, - { "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] }, - { "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] }, - { "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] }, - { "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] }, - { "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] }, - { "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] }, - { "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] }, - { "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] }, - { "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] }, - { "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] }, - { "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] }, - { "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] }, - { "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] }, - { "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] }, - { "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] }, - { "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, - { "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] }, - { "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] }, - { "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, - { "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] }, - { "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] }, - { "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] }, - { "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] }, - { "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] }, - { "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] }, - { "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] }, - { "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] }, - { "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] }, - { "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] }, - { "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] }, - { "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] }, - { "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] }, - { "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] }, - { "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] }, - { "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] }, - { "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] }, - { "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] }, - { "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] }, - { "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] }, - { "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] }, - { "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] }, - { "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] }, - { "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] }, - { "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] }, - { "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] }, - { "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] }, - { "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] }, - { "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] }, - { "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] }, - { "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] }, - { "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] }, - { "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] }, - { "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] }, - { "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] }, - { "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] }, - { "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] }, - { "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] }, - { "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] }, - { "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] }, - { "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] }, - { "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] }, - { "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] }, - { "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] }, - { "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] }, - { "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] }, - { "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] }, - { "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] }, - { "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] }, - { "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] }, - { "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] }, - { "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] }, - { "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] }, - { "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] }, - { "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] }, - { "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] }, - { "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] }, - { "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] }, - { "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] }, - { "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] }, - { "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] }, - { "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] }, - { "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] }, - { "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] }, - { "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] }, - { "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] }, - { "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] }, - { "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] }, - { "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] }, - { "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] }, - { "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] }, - { "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] }, - { "category": "face", "char": "\uD83E\uDEE0", "name": "melting_face", "keywords": ["disappear", "dissolve", "liquid", "melt", "toketa"] }, - { "category": "face", "char": "\uD83E\uDEE2", "name": "face_with_open_eyes_and_hand_over_mouth", "keywords": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"] }, - { "category": "face", "char": "\uD83E\uDEE3", "name": "face_with_peeking_eye", "keywords": ["captivated", "peep", "stare", "chunibyo"] }, - { "category": "face", "char": "\uD83E\uDEE1", "name": "saluting_face", "keywords": ["ok", "salute", "sunny", "troops", "yes", "raja"] }, - { "category": "face", "char": "\uD83E\uDEE5", "name": "dotted_line_face", "keywords": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"] }, - { "category": "face", "char": "\uD83E\uDEE4", "name": "face_with_diagonal_mouth", "keywords": ["disappointed", "meh", "skeptical", "unsure"] }, - { "category": "face", "char": "\uD83E\uDD79", "name": "face_holding_back_tears", "keywords": ["angry", "cry", "proud", "resist", "sad"] }, - { "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] }, - { "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] }, - { "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] }, - { "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] }, - { "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] }, - { "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] }, - { "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] }, - { "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] }, - { "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] }, - { "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] }, - { "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] }, - { "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] }, - { "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] }, - { "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] }, - { "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] }, - { "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] }, - { "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] }, - { "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] }, - { "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] }, - { "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] }, - { "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] }, - { "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] }, - { "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] }, - { "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] }, - { "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] }, - { "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] }, - { "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] }, - { "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] }, - { "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] }, - { "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] }, - { "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] }, - { "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] }, - { "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] }, - { "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] }, - { "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] }, - { "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] }, - { "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] }, - { "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] }, - { "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] }, - { "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] }, - { "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] }, - { "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] }, - { "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] }, - { "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] }, - { "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] }, - { "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] }, - { "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] }, - { "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] }, - { "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] }, - { "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] }, - { "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] }, - { "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] }, - { "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] }, - { "category": "people", "char": "\uD83E\uDEF0", "name": "hand_with_index_finger_and_thumb_crossed", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEF1", "name": "rightwards_hand", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEF2", "name": "leftwards_hand", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEF3", "name": "palm_down_hand", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEF4", "name": "palm_up_hand", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEF5", "name": "index_pointing_at_the_viewer", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEF6", "name": "heart_hands", "keywords": ["moemoekyun"] }, - { "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] }, - { "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] }, - { "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] }, - { "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] }, - { "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] }, - { "category": "people", "char": "\uD83E\uDEE6", "name": "biting_lip", "keywords": [] }, - { "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] }, - { "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] }, - { "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] }, - { "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] }, - { "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] }, - { "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] }, - { "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] }, - { "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] }, - { "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] }, - { "category": "people", "char": "🫁", "name": "lungs", "keywords": [] }, - { "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] }, - { "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] }, - { "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] }, - { "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] }, - { "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] }, - { "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] }, - { "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] }, - { "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] }, - { "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] }, - { "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] }, - { "category": "people", "char": "🧑🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] }, - { "category": "people", "char": "👩🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] }, - { "category": "people", "char": "👨🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] }, - { "category": "people", "char": "🧑🦰", "name": "red_hair", "keywords": ["redhead"] }, - { "category": "people", "char": "👩🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] }, - { "category": "people", "char": "👨🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] }, - { "category": "people", "char": "👱♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] }, - { "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] }, - { "category": "people", "char": "🧑🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] }, - { "category": "people", "char": "👩🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] }, - { "category": "people", "char": "👨🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] }, - { "category": "people", "char": "🧑🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] }, - { "category": "people", "char": "👩🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] }, - { "category": "people", "char": "👨🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] }, - { "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] }, - { "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] }, - { "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] }, - { "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] }, - { "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] }, - { "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] }, - { "category": "people", "char": "👳♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] }, - { "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] }, - { "category": "people", "char": "👮♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] }, - { "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] }, - { "category": "people", "char": "👷♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] }, - { "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] }, - { "category": "people", "char": "💂♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] }, - { "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] }, - { "category": "people", "char": "🕵️♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] }, - { "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] }, - { "category": "people", "char": "🧑⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] }, - { "category": "people", "char": "👩⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] }, - { "category": "people", "char": "👨⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] }, - { "category": "people", "char": "🧑🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] }, - { "category": "people", "char": "👩🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] }, - { "category": "people", "char": "👨🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] }, - { "category": "people", "char": "🧑🍳", "name": "cook", "keywords": ["chef", "human"] }, - { "category": "people", "char": "👩🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] }, - { "category": "people", "char": "👨🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] }, - { "category": "people", "char": "🧑🎓", "name": "student", "keywords": ["graduate", "human"] }, - { "category": "people", "char": "👩🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] }, - { "category": "people", "char": "👨🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] }, - { "category": "people", "char": "🧑🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] }, - { "category": "people", "char": "👩🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] }, - { "category": "people", "char": "👨🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] }, - { "category": "people", "char": "🧑🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] }, - { "category": "people", "char": "👩🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] }, - { "category": "people", "char": "👨🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] }, - { "category": "people", "char": "🧑🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] }, - { "category": "people", "char": "👩🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] }, - { "category": "people", "char": "👨🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] }, - { "category": "people", "char": "🧑💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] }, - { "category": "people", "char": "👩💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] }, - { "category": "people", "char": "👨💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] }, - { "category": "people", "char": "🧑💼", "name": "office_worker", "keywords": ["business", "manager", "human"] }, - { "category": "people", "char": "👩💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] }, - { "category": "people", "char": "👨💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] }, - { "category": "people", "char": "🧑🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] }, - { "category": "people", "char": "👩🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] }, - { "category": "people", "char": "👨🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] }, - { "category": "people", "char": "🧑🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] }, - { "category": "people", "char": "👩🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] }, - { "category": "people", "char": "👨🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] }, - { "category": "people", "char": "🧑🎨", "name": "artist", "keywords": ["painter", "human"] }, - { "category": "people", "char": "👩🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] }, - { "category": "people", "char": "👨🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] }, - { "category": "people", "char": "🧑🚒", "name": "firefighter", "keywords": ["fireman", "human"] }, - { "category": "people", "char": "👩🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] }, - { "category": "people", "char": "👨🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] }, - { "category": "people", "char": "🧑✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] }, - { "category": "people", "char": "👩✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] }, - { "category": "people", "char": "👨✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] }, - { "category": "people", "char": "🧑🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] }, - { "category": "people", "char": "👩🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] }, - { "category": "people", "char": "👨🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] }, - { "category": "people", "char": "🧑⚖️", "name": "judge", "keywords": ["justice", "court", "human"] }, - { "category": "people", "char": "👩⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] }, - { "category": "people", "char": "👨⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] }, - { "category": "people", "char": "🦸♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] }, - { "category": "people", "char": "🦸♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] }, - { "category": "people", "char": "🦹♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] }, - { "category": "people", "char": "🦹♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] }, - { "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] }, - { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] }, - { "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] }, - { "category": "people", "char": "🥷", "name": "ninja", "keywords": [] }, - { "category": "people", "char": "🧙♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] }, - { "category": "people", "char": "🧙♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] }, - { "category": "people", "char": "🧝♀️", "name": "woman_elf", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧝♂️", "name": "man_elf", "keywords": ["man", "male"] }, - { "category": "people", "char": "🧛♀️", "name": "woman_vampire", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧛♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] }, - { "category": "people", "char": "🧟♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] }, - { "category": "people", "char": "🧟♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] }, - { "category": "people", "char": "🧞♀️", "name": "woman_genie", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧞♂️", "name": "man_genie", "keywords": ["man", "male"] }, - { "category": "people", "char": "🧜♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] }, - { "category": "people", "char": "🧜♂️", "name": "merman", "keywords": ["man", "male", "triton"] }, - { "category": "people", "char": "🧚♀️", "name": "woman_fairy", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧚♂️", "name": "man_fairy", "keywords": ["man", "male"] }, - { "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] }, - { "category": "people", "char": "\uD83E\uDDCC", "name": "troll", "keywords": [] }, - { "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] }, - { "category": "people", "char": "\uD83E\uDEC3", "name": "pregnant_man", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEC4", "name": "pregnant_person", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDEC5", "name": "person_with_crown", "keywords": [] }, - { "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] }, - { "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] }, - { "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] }, - { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] }, - { "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] }, - { "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] }, - { "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, - { "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, - { "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, - { "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, - { "category": "people", "char": "🏃♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] }, - { "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] }, - { "category": "people", "char": "🚶♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] }, - { "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] }, - { "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] }, - { "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] }, - { "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] }, - { "category": "people", "char": "👯♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] }, - { "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] }, - { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] }, - { "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] }, - { "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] }, - { "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] }, - { "category": "people", "char": "🙇♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] }, - { "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] }, - { "category": "people", "char": "🤦♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] }, - { "category": "people", "char": "🤦♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] }, - { "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] }, - { "category": "people", "char": "🤷♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] }, - { "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] }, - { "category": "people", "char": "💁♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] }, - { "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] }, - { "category": "people", "char": "🙅♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] }, - { "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] }, - { "category": "people", "char": "🙆♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] }, - { "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] }, - { "category": "people", "char": "🙋♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] }, - { "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] }, - { "category": "people", "char": "🙎♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] }, - { "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] }, - { "category": "people", "char": "🙍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] }, - { "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] }, - { "category": "people", "char": "💇♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] }, - { "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] }, - { "category": "people", "char": "💆♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] }, - { "category": "people", "char": "🧖♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] }, - { "category": "people", "char": "🧖♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] }, - { "category": "people", "char": "🧏♀️", "name": "woman_deaf", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧏♂️", "name": "man_deaf", "keywords": ["man", "male"] }, - { "category": "people", "char": "🧍♀️", "name": "woman_standing", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧍♂️", "name": "man_standing", "keywords": ["man", "male"] }, - { "category": "people", "char": "🧎♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] }, - { "category": "people", "char": "🧎♂️", "name": "man_kneeling", "keywords": ["man", "male"] }, - { "category": "people", "char": "🧑🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] }, - { "category": "people", "char": "👩🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] }, - { "category": "people", "char": "👨🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] }, - { "category": "people", "char": "🧑🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] }, - { "category": "people", "char": "👩🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] }, - { "category": "people", "char": "👨🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] }, - { "category": "people", "char": "🧑🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] }, - { "category": "people", "char": "👩🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] }, - { "category": "people", "char": "👨🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] }, - { "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, - { "category": "people", "char": "👩❤️👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, - { "category": "people", "char": "👨❤️👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, - { "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, - { "category": "people", "char": "👩❤️💋👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, - { "category": "people", "char": "👨❤️💋👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, - { "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] }, - { "category": "people", "char": "👨👩👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] }, - { "category": "people", "char": "👨👩👧👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👩👦👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👩👧👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👩👩👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👩👩👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👩👩👧👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👩👩👦👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👩👩👧👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👨👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👨👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👨👧👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👨👦👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👨👨👧👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, - { "category": "people", "char": "👩👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] }, - { "category": "people", "char": "👩👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] }, - { "category": "people", "char": "👩👧👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, - { "category": "people", "char": "👩👦👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, - { "category": "people", "char": "👩👧👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, - { "category": "people", "char": "👨👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] }, - { "category": "people", "char": "👨👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] }, - { "category": "people", "char": "👨👧👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, - { "category": "people", "char": "👨👦👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, - { "category": "people", "char": "👨👧👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, - { "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] }, - { "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] }, - { "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] }, - { "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] }, - { "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] }, - { "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] }, - { "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] }, - { "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] }, - { "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] }, - { "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, - { "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, - { "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] }, - { "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] }, - { "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] }, - { "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] }, - { "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] }, - { "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] }, - { "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] }, - { "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] }, - { "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] }, - { "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] }, - { "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] }, - { "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] }, - { "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] }, - { "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] }, - { "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] }, - { "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] }, - { "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] }, - { "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] }, - { "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] }, - { "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] }, - { "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] }, - { "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] }, - { "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] }, - { "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] }, - { "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] }, - { "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] }, - { "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] }, - { "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] }, - { "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] }, - { "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] }, - { "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] }, - { "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] }, - { "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] }, - { "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] }, - { "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] }, - { "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] }, - { "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] }, - { "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, - { "category": "animals_and_nature", "char": "🐈⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, - { "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] }, - { "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] }, - { "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] }, - { "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] }, - { "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] }, - { "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] }, - { "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, - { "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] }, - { "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] }, - { "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] }, - { "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] }, - { "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] }, - { "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] }, - { "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] }, - { "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] }, - { "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] }, - { "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] }, - { "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] }, - { "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] }, - { "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] }, - { "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] }, - { "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] }, - { "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] }, - { "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] }, - { "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] }, - { "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] }, - { "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] }, - { "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] }, - { "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] }, - { "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] }, - { "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] }, - { "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] }, - { "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] }, - { "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] }, - { "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] }, - { "category": "animals_and_nature", "char": "🐞", "name": "lady_beetle", "keywords": ["animal", "insect", "nature", "ladybug"] }, - { "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] }, - { "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] }, - { "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] }, - { "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] }, - { "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] }, - { "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] }, - { "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] }, - { "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] }, - { "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] }, - { "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] }, - { "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] }, - { "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] }, - { "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] }, - { "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] }, - { "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] }, - { "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] }, - { "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] }, - { "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] }, - { "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] }, - { "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] }, - { "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] }, - { "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] }, - { "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] }, - { "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] }, - { "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] }, - { "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] }, - { "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, - { "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] }, - { "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] }, - { "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] }, - { "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] }, - { "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] }, - { "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] }, - { "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] }, - { "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] }, - { "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] }, - { "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] }, - { "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] }, - { "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] }, - { "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] }, - { "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] }, - { "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] }, - { "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] }, - { "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] }, - { "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] }, - { "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] }, - { "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] }, - { "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] }, - { "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] }, - { "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] }, - { "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] }, - { "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] }, - { "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] }, - { "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] }, - { "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] }, - { "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] }, - { "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐻❄️", "name": "polar_bear", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] }, - { "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, - { "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, - { "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🐕🦺", "name": "service_dog", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] }, - { "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] }, - { "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] }, - { "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] }, - { "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] }, - { "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] }, - { "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] }, - { "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] }, - { "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] }, - { "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] }, - { "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] }, - { "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] }, - { "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] }, - { "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] }, - { "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] }, - { "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] }, - { "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] }, - { "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] }, - { "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] }, - { "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] }, - { "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] }, - { "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] }, - { "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] }, - { "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] }, - { "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] }, - { "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] }, - { "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] }, - { "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] }, - { "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] }, - { "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] }, - { "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] }, - { "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] }, - { "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] }, - { "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] }, - { "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] }, - { "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, - { "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] }, - { "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] }, - { "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] }, - { "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] }, - { "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] }, - { "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] }, - { "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] }, - { "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] }, - { "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] }, - { "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] }, - { "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] }, - { "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] }, - { "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] }, - { "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] }, - { "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] }, - { "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] }, - { "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] }, - { "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] }, - { "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] }, - { "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] }, - { "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] }, - { "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] }, - { "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] }, - { "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] }, - { "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] }, - { "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] }, - { "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] }, - { "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] }, - { "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] }, - { "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] }, - { "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] }, - { "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] }, - { "category": "animals_and_nature", "char": "\uD83E\uDEB7", "name": "lotus", "keywords": [] }, - { "category": "animals_and_nature", "char": "\uD83E\uDEB8", "name": "coral", "keywords": [] }, - { "category": "animals_and_nature", "char": "\uD83E\uDEB9", "name": "empty_nest", "keywords": [] }, - { "category": "animals_and_nature", "char": "\uD83E\uDEBA", "name": "nest_with_eggs", "keywords": [] }, - { "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] }, - { "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] }, - { "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] }, - { "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] }, - { "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] }, - { "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] }, - { "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] }, - { "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] }, - { "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] }, - { "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] }, - { "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] }, - { "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] }, - { "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] }, - { "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] }, - { "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] }, - { "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] }, - { "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] }, - { "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] }, - { "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] }, - { "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] }, - { "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] }, - { "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] }, - { "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] }, - { "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] }, - { "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] }, - { "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] }, - { "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] }, - { "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] }, - { "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] }, - { "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] }, - { "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] }, - { "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] }, - { "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] }, - { "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] }, - { "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] }, - { "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] }, - { "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] }, - { "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] }, - { "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] }, - { "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] }, - { "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] }, - { "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] }, - { "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] }, - { "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] }, - { "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] }, - { "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] }, - { "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] }, - { "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] }, - { "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] }, - { "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] }, - { "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] }, - { "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] }, - { "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] }, - { "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] }, - { "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] }, - { "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] }, - { "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] }, - { "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] }, - { "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] }, - { "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] }, - { "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] }, - { "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] }, - { "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] }, - { "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] }, - { "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] }, - { "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] }, - { "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] }, - { "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] }, - { "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] }, - { "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] }, - { "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] }, - { "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] }, - { "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] }, - { "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] }, - { "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] }, - { "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] }, - { "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] }, - { "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] }, - { "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] }, - { "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] }, - { "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] }, - { "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] }, - { "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] }, - { "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] }, - { "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] }, - { "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] }, - { "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] }, - { "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] }, - { "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, - { "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, - { "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] }, - { "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] }, - { "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] }, - { "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] }, - { "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] }, - { "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] }, - { "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] }, - { "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] }, - { "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] }, - { "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] }, - { "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] }, - { "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] }, - { "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] }, - { "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] }, - { "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] }, - { "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] }, - { "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] }, - { "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] }, - { "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] }, - { "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] }, - { "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] }, - { "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] }, - { "category": "food_and_drink", "char": "\uD83E\uDED7", "name": "pouring_liquid", "keywords": [] }, - { "category": "food_and_drink", "char": "\uD83E\uDED8", "name": "beans", "keywords": [] }, - { "category": "food_and_drink", "char": "\uD83E\uDED9", "name": "jar", "keywords": [] }, - { "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] }, - { "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] }, - { "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] }, - { "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] }, - { "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] }, - { "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] }, - { "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] }, - { "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] }, - { "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] }, - { "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] }, - { "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] }, - { "category": "activity", "char": "🏌️♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] }, - { "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] }, - { "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] }, - { "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] }, - { "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] }, - { "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] }, - { "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] }, - { "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] }, - { "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] }, - { "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] }, - { "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] }, - { "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] }, - { "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] }, - { "category": "activity", "char": "🤼♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] }, - { "category": "activity", "char": "🤼♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] }, - { "category": "activity", "char": "🤸♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] }, - { "category": "activity", "char": "🤸♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] }, - { "category": "activity", "char": "🤾♀️", "name": "woman_playing_handball", "keywords": ["sports"] }, - { "category": "activity", "char": "🤾♂️", "name": "man_playing_handball", "keywords": ["sports"] }, - { "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] }, - { "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] }, - { "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] }, - { "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] }, - { "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] }, - { "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] }, - { "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] }, - { "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] }, - { "category": "activity", "char": "🚣♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] }, - { "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] }, - { "category": "activity", "char": "🧗♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] }, - { "category": "activity", "char": "🧗♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] }, - { "category": "activity", "char": "🏊♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] }, - { "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] }, - { "category": "activity", "char": "🤽♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] }, - { "category": "activity", "char": "🤽♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] }, - { "category": "activity", "char": "🧘♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, - { "category": "activity", "char": "🧘♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, - { "category": "activity", "char": "🏄♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] }, - { "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] }, - { "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] }, - { "category": "activity", "char": "⛹️♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] }, - { "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] }, - { "category": "activity", "char": "🏋️♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] }, - { "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] }, - { "category": "activity", "char": "🚴♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] }, - { "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] }, - { "category": "activity", "char": "🚵♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] }, - { "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] }, - { "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] }, - { "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] }, - { "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] }, - { "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] }, - { "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] }, - { "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] }, - { "category": "activity", "char": "🪢", "name": "knot", "keywords": [] }, - { "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] }, - { "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] }, - { "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] }, - { "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] }, - { "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] }, - { "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] }, - { "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] }, - { "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] }, - { "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] }, - { "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] }, - { "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] }, - { "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] }, - { "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] }, - { "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] }, - { "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] }, - { "category": "activity", "char": "🤹♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, - { "category": "activity", "char": "🤹♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, - { "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] }, - { "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] }, - { "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] }, - { "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] }, - { "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] }, - { "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] }, - { "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] }, - { "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] }, - { "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] }, - { "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] }, - { "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] }, - { "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] }, - { "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] }, - { "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] }, - { "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] }, - { "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] }, - { "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] }, - { "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] }, - { "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] }, - { "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] }, - { "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] }, - { "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] }, - { "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] }, - { "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] }, - { "category": "activity", "char": "\uD83E\uDEAC", "name": "hamsa", "keywords": [] }, - { "category": "activity", "char": "\uD83E\uDEA9", "name": "mirror_ball", "keywords": [] }, - { "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] }, - { "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] }, - { "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] }, - { "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] }, - { "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] }, - { "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] }, - { "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] }, - { "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] }, - { "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] }, - { "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] }, - { "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] }, - { "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] }, - { "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] }, - { "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] }, - { "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] }, - { "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] }, - { "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] }, - { "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] }, - { "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] }, - { "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] }, - { "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] }, - { "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] }, - { "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] }, - { "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] }, - { "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] }, - { "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] }, - { "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] }, - { "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] }, - { "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] }, - { "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] }, - { "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] }, - { "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] }, - { "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] }, - { "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] }, - { "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] }, - { "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] }, - { "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] }, - { "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] }, - { "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] }, - { "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] }, - { "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] }, - { "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] }, - { "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] }, - { "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] }, - { "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] }, - { "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] }, - { "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] }, - { "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] }, - { "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] }, - { "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] }, - { "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] }, - { "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] }, - { "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] }, - { "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] }, - { "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] }, - { "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] }, - { "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] }, - { "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] }, - { "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] }, - { "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] }, - { "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] }, - { "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] }, - { "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] }, - { "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] }, - { "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] }, - { "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] }, - { "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] }, - { "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] }, - { "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] }, - { "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] }, - { "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] }, - { "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] }, - { "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] }, - { "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] }, - { "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] }, - { "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] }, - { "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] }, - { "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] }, - { "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] }, - { "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] }, - { "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] }, - { "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] }, - { "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] }, - { "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] }, - { "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] }, - { "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] }, - { "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] }, - { "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] }, - { "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] }, - { "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] }, - { "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] }, - { "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] }, - { "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] }, - { "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] }, - { "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] }, - { "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] }, - { "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] }, - { "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] }, - { "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] }, - { "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] }, - { "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] }, - { "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] }, - { "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] }, - { "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] }, - { "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] }, - { "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] }, - { "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] }, - { "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] }, - { "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] }, - { "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] }, - { "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] }, - { "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] }, - { "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] }, - { "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] }, - { "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] }, - { "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] }, - { "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] }, - { "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] }, - { "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] }, - { "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] }, - { "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] }, - { "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] }, - { "category": "travel_and_places", "char": "\uD83D\uDEDD", "name": "playground_slide", "keywords": [] }, - { "category": "travel_and_places", "char": "\uD83D\uDEDE", "name": "wheel", "keywords": [] }, - { "category": "travel_and_places", "char": "\uD83D\uDEDF", "name": "ring_buoy", "keywords": [] }, - { "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] }, - { "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] }, - { "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] }, - { "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] }, - { "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] }, - { "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] }, - { "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] }, - { "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] }, - { "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] }, - { "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] }, - { "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] }, - { "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] }, - { "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] }, - { "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] }, - { "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] }, - { "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] }, - { "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] }, - { "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] }, - { "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] }, - { "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] }, - { "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] }, - { "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] }, - { "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] }, - { "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] }, - { "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] }, - { "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] }, - { "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] }, - { "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] }, - { "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] }, - { "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] }, - { "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] }, - { "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] }, - { "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] }, - { "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] }, - { "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] }, - { "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] }, - { "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] }, - { "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] }, - { "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] }, - { "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] }, - { "category": "objects", "char": "\uD83E\uDEAB", "name": "battery", "keywords": [] }, - { "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] }, - { "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] }, - { "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] }, - { "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] }, - { "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] }, - { "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] }, - { "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] }, - { "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] }, - { "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] }, - { "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] }, - { "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] }, - { "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] }, - { "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] }, - { "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] }, - { "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] }, - { "category": "objects", "char": "\uD83E\uDEAB", "name": "identification_card", "keywords": [] }, - { "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] }, - { "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] }, - { "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] }, - { "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] }, - { "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] }, - { "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] }, - { "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] }, - { "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] }, - { "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] }, - { "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] }, - { "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] }, - { "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] }, - { "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] }, - { "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] }, - { "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] }, - { "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] }, - { "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] }, - { "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] }, - { "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] }, - { "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] }, - { "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] }, - { "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] }, - { "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] }, - { "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] }, - { "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] }, - { "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] }, - { "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] }, - { "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] }, - { "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] }, - { "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] }, - { "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] }, - { "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] }, - { "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] }, - { "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] }, - { "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] }, - { "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] }, - { "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] }, - { "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] }, - { "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] }, - { "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] }, - { "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] }, - { "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] }, - { "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, - { "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, - { "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, - { "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] }, - { "category": "objects", "char": "\uD83E\uDE7B", "name": "xray", "keywords": [] }, - { "category": "objects", "char": "\uD83E\uDE7C", "name": "crutch", "keywords": [] }, - { "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] }, - { "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] }, - { "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] }, - { "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] }, - { "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] }, - { "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] }, - { "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] }, - { "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] }, - { "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] }, - { "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] }, - { "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] }, - { "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] }, - { "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] }, - { "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] }, - { "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] }, - { "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] }, - { "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] }, - { "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] }, - { "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] }, - { "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] }, - { "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] }, - { "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] }, - { "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] }, - { "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] }, - { "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] }, - { "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] }, - { "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] }, - { "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] }, - { "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] }, - { "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] }, - { "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] }, - { "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] }, - { "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] }, - { "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] }, - { "category": "objects", "char": "\uD83E\uDEE7", "name": "bubbles", "keywords": [] }, - { "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] }, - { "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] }, - { "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] }, - { "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] }, - { "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] }, - { "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] }, - { "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] }, - { "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] }, - { "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] }, - { "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] }, - { "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] }, - { "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] }, - { "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] }, - { "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] }, - { "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] }, - { "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] }, - { "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] }, - { "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] }, - { "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] }, - { "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] }, - { "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] }, - { "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] }, - { "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] }, - { "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] }, - { "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] }, - { "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] }, - { "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] }, - { "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] }, - { "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] }, - { "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] }, - { "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] }, - { "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] }, - { "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] }, - { "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] }, - { "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] }, - { "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] }, - { "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] }, - { "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] }, - { "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] }, - { "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] }, - { "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] }, - { "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] }, - { "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] }, - { "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] }, - { "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] }, - { "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] }, - { "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] }, - { "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] }, - { "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] }, - { "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] }, - { "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] }, - { "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] }, - { "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] }, - { "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] }, - { "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] }, - { "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] }, - { "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] }, - { "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] }, - { "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] }, - { "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] }, - { "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] }, - { "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] }, - { "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] }, - { "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] }, - { "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] }, - { "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] }, - { "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] }, - { "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] }, - { "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] }, - { "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] }, - { "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] }, - { "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] }, - { "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] }, - { "category": "objects", "char": "🏳️🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] }, - { "category": "objects", "char": "🏳️⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] }, - { "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] }, - { "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] }, - { "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] }, - { "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] }, - { "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] }, - { "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] }, - { "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] }, - { "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] }, - { "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] }, - { "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] }, - { "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] }, - { "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] }, - { "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] }, - { "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] }, - { "category": "objects", "char": "🪧", "name": "placard", "keywords": [] }, - { "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] }, - { "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] }, - { "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] }, - { "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] }, - { "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] }, - { "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] }, - { "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] }, - { "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] }, - { "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] }, - { "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] }, - { "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] }, - { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] }, - { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] }, - { "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] }, - { "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] }, - { "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] }, - { "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, - { "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, - { "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] }, - { "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] }, - { "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] }, - { "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] }, - { "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] }, - { "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] }, - { "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] }, - { "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, - { "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, - { "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, - { "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, - { "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, - { "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, - { "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, - { "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] }, - { "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, - { "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, - { "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, - { "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, - { "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] }, - { "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] }, - { "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, - { "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] }, - { "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] }, - { "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] }, - { "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] }, - { "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] }, - { "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] }, - { "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] }, - { "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] }, - { "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] }, - { "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] }, - { "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] }, - { "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] }, - { "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] }, - { "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] }, - { "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] }, - { "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] }, - { "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] }, - { "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] }, - { "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] }, - { "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] }, - { "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] }, - { "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] }, - { "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] }, - { "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] }, - { "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] }, - { "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] }, - { "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] }, - { "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] }, - { "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] }, - { "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] }, - { "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] }, - { "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] }, - { "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] }, - { "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] }, - { "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] }, - { "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] }, - { "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] }, - { "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] }, - { "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] }, - { "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] }, - { "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] }, - { "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] }, - { "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] }, - { "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] }, - { "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] }, - { "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] }, - { "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] }, - { "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] }, - { "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] }, - { "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] }, - { "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] }, - { "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] }, - { "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] }, - { "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] }, - { "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] }, - { "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] }, - { "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] }, - { "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] }, - { "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] }, - { "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] }, - { "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] }, - { "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] }, - { "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] }, - { "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] }, - { "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] }, - { "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] }, - { "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] }, - { "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] }, - { "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] }, - { "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] }, - { "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] }, - { "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] }, - { "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] }, - { "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] }, - { "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] }, - { "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] }, - { "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] }, - { "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] }, - { "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] }, - { "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, - { "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] }, - { "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] }, - { "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] }, - { "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] }, - { "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] }, - { "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] }, - { "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] }, - { "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] }, - { "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] }, - { "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] }, - { "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] }, - { "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] }, - { "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] }, - { "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] }, - { "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] }, - { "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] }, - { "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] }, - { "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] }, - { "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] }, - { "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] }, - { "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] }, - { "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] }, - { "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] }, - { "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] }, - { "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] }, - { "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] }, - { "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] }, - { "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] }, - { "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] }, - { "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] }, - { "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] }, - { "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] }, - { "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] }, - { "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] }, - { "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] }, - { "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] }, - { "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] }, - { "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] }, - { "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] }, - { "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] }, - { "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] }, - { "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] }, - { "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] }, - { "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] }, - { "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] }, - { "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] }, - { "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] }, - { "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] }, - { "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] }, - { "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] }, - { "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] }, - { "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] }, - { "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] }, - { "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] }, - { "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] }, - { "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] }, - { "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] }, - { "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] }, - { "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] }, - { "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] }, - { "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] }, - { "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] }, - { "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] }, - { "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] }, - { "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] }, - { "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] }, - { "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] }, - { "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] }, - { "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] }, - { "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] }, - { "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] }, - { "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] }, - { "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] }, - { "category": "symbols", "char": "\uD83D\uDFF0", "name": "heavy_equals_sign", "keywords": [] }, - { "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] }, - { "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] }, - { "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] }, - { "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] }, - { "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] }, - { "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] }, - { "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] }, - { "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] }, - { "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] }, - { "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] }, - { "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] }, - { "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] }, - { "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] }, - { "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] }, - { "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] }, - { "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] }, - { "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] }, - { "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] }, - { "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] }, - { "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, - { "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, - { "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, - { "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, - { "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] }, - { "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] }, - { "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] }, - { "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] }, - { "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] }, - { "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] }, - { "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] }, - { "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] }, - { "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] }, - { "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] }, - { "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] }, - { "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] }, - { "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] }, - { "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] }, - { "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] }, - { "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] }, - { "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] }, - { "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] }, - { "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] }, - { "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] }, - { "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] }, - { "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] }, - { "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] }, - { "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] }, - { "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] }, - { "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] }, - { "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] }, - { "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] }, - { "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] }, - { "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] }, - { "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] }, - { "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] }, - { "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] }, - { "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] }, - { "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] }, - { "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] }, - { "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] }, - { "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] }, - { "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] }, - { "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] }, - { "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] }, - { "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] }, - { "category": "flags", "char": "🏴", "name": "england", "keywords": ["flag", "english"] }, - { "category": "flags", "char": "🏴", "name": "scotland", "keywords": ["flag", "scottish"] }, - { "category": "flags", "char": "🏴", "name": "wales", "keywords": ["flag", "welsh"] }, - { "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] }, - { "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] }, - { "category": "flags", "char": "🏴☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] } + ["😀", "grinning", 0], + ["😬", "grimacing", 0], + ["😁", "grin", 0], + ["😂", "joy", 0], + ["🤣", "rofl", 0], + ["🥳", "partying", 0], + ["😃", "smiley", 0], + ["😄", "smile", 0], + ["😅", "sweat_smile", 0], + ["🥲", "smiling_face_with_tear", 0], + ["😆", "laughing", 0], + ["😇", "innocent", 0], + ["😉", "wink", 0], + ["😊", "blush", 0], + ["🙂", "slightly_smiling_face", 0], + ["🙃", "upside_down_face", 0], + ["☺️", "relaxed", 0], + ["😋", "yum", 0], + ["😌", "relieved", 0], + ["😍", "heart_eyes", 0], + ["🥰", "smiling_face_with_three_hearts", 0], + ["😘", "kissing_heart", 0], + ["😗", "kissing", 0], + ["😙", "kissing_smiling_eyes", 0], + ["😚", "kissing_closed_eyes", 0], + ["😜", "stuck_out_tongue_winking_eye", 0], + ["🤪", "zany", 0], + ["🤨", "raised_eyebrow", 0], + ["🧐", "monocle", 0], + ["😝", "stuck_out_tongue_closed_eyes", 0], + ["😛", "stuck_out_tongue", 0], + ["🤑", "money_mouth_face", 0], + ["🤓", "nerd_face", 0], + ["🥸", "disguised_face", 0], + ["😎", "sunglasses", 0], + ["🤩", "star_struck", 0], + ["🤡", "clown_face", 0], + ["🤠", "cowboy_hat_face", 0], + ["🤗", "hugs", 0], + ["😏", "smirk", 0], + ["😶", "no_mouth", 0], + ["😐", "neutral_face", 0], + ["😑", "expressionless", 0], + ["😒", "unamused", 0], + ["🙄", "roll_eyes", 0], + ["🤔", "thinking", 0], + ["🤥", "lying_face", 0], + ["🤭", "hand_over_mouth", 0], + ["🤫", "shushing", 0], + ["🤬", "symbols_over_mouth", 0], + ["🤯", "exploding_head", 0], + ["😳", "flushed", 0], + ["😞", "disappointed", 0], + ["😟", "worried", 0], + ["😠", "angry", 0], + ["😡", "rage", 0], + ["😔", "pensive", 0], + ["😕", "confused", 0], + ["🙁", "slightly_frowning_face", 0], + ["☹", "frowning_face", 0], + ["😣", "persevere", 0], + ["😖", "confounded", 0], + ["😫", "tired_face", 0], + ["😩", "weary", 0], + ["🥺", "pleading", 0], + ["😤", "triumph", 0], + ["😮", "open_mouth", 0], + ["😱", "scream", 0], + ["😨", "fearful", 0], + ["😰", "cold_sweat", 0], + ["😯", "hushed", 0], + ["😦", "frowning", 0], + ["😧", "anguished", 0], + ["😢", "cry", 0], + ["😥", "disappointed_relieved", 0], + ["🤤", "drooling_face", 0], + ["😪", "sleepy", 0], + ["😓", "sweat", 0], + ["🥵", "hot", 0], + ["🥶", "cold", 0], + ["😭", "sob", 0], + ["😵", "dizzy_face", 0], + ["😲", "astonished", 0], + ["🤐", "zipper_mouth_face", 0], + ["🤢", "nauseated_face", 0], + ["🤧", "sneezing_face", 0], + ["🤮", "vomiting", 0], + ["😷", "mask", 0], + ["🤒", "face_with_thermometer", 0], + ["🤕", "face_with_head_bandage", 0], + ["🥴", "woozy", 0], + ["🥱", "yawning", 0], + ["😴", "sleeping", 0], + ["💤", "zzz", 0], + ["😶🌫️", "face_in_clouds", 0], + ["😮💨", "face_exhaling", 0], + ["😵💫", "face_with_spiral_eyes", 0], + ["🫠", "melting_face", 0], + ["🫢", "face_with_open_eyes_and_hand_over_mouth", 0], + ["🫣", "face_with_peeking_eye", 0], + ["🫡", "saluting_face", 0], + ["🫥", "dotted_line_face", 0], + ["🫤", "face_with_diagonal_mouth", 0], + ["🥹", "face_holding_back_tears", 0], + ["💩", "poop", 0], + ["😈", "smiling_imp", 0], + ["👿", "imp", 0], + ["👹", "japanese_ogre", 0], + ["👺", "japanese_goblin", 0], + ["💀", "skull", 0], + ["👻", "ghost", 0], + ["👽", "alien", 0], + ["🤖", "robot", 0], + ["😺", "smiley_cat", 0], + ["😸", "smile_cat", 0], + ["😹", "joy_cat", 0], + ["😻", "heart_eyes_cat", 0], + ["😼", "smirk_cat", 0], + ["😽", "kissing_cat", 0], + ["🙀", "scream_cat", 0], + ["😿", "crying_cat_face", 0], + ["😾", "pouting_cat", 0], + ["🤲", "palms_up", 1], + ["🙌", "raised_hands", 1], + ["👏", "clap", 1], + ["👋", "wave", 1], + ["🤙", "call_me_hand", 1], + ["👍", "+1", 1], + ["👎", "-1", 1], + ["👊", "facepunch", 1], + ["✊", "fist", 1], + ["🤛", "fist_left", 1], + ["🤜", "fist_right", 1], + ["✌", "v", 1], + ["👌", "ok_hand", 1], + ["✋", "raised_hand", 1], + ["🤚", "raised_back_of_hand", 1], + ["👐", "open_hands", 1], + ["💪", "muscle", 1], + ["🦾", "mechanical_arm", 1], + ["🙏", "pray", 1], + ["🦶", "foot", 1], + ["🦵", "leg", 1], + ["🦿", "mechanical_leg", 1], + ["🤝", "handshake", 1], + ["☝", "point_up", 1], + ["👆", "point_up_2", 1], + ["👇", "point_down", 1], + ["👈", "point_left", 1], + ["👉", "point_right", 1], + ["🖕", "fu", 1], + ["🖐", "raised_hand_with_fingers_splayed", 1], + ["🤟", "love_you", 1], + ["🤘", "metal", 1], + ["🤞", "crossed_fingers", 1], + ["🖖", "vulcan_salute", 1], + ["✍", "writing_hand", 1], + ["🫰", "hand_with_index_finger_and_thumb_crossed", 1], + ["🫱", "rightwards_hand", 1], + ["🫲", "leftwards_hand", 1], + ["🫳", "palm_down_hand", 1], + ["🫴", "palm_up_hand", 1], + ["🫵", "index_pointing_at_the_viewer", 1], + ["🫶", "heart_hands", 1], + ["🤏", "pinching_hand", 1], + ["🤌", "pinched_fingers", 1], + ["🤳", "selfie", 1], + ["💅", "nail_care", 1], + ["👄", "lips", 1], + ["🫦", "biting_lip", 1], + ["🦷", "tooth", 1], + ["👅", "tongue", 1], + ["👂", "ear", 1], + ["🦻", "ear_with_hearing_aid", 1], + ["👃", "nose", 1], + ["👁", "eye", 1], + ["👀", "eyes", 1], + ["🧠", "brain", 1], + ["🫀", "anatomical_heart", 1], + ["🫁", "lungs", 1], + ["👤", "bust_in_silhouette", 1], + ["👥", "busts_in_silhouette", 1], + ["🗣", "speaking_head", 1], + ["👶", "baby", 1], + ["🧒", "child", 1], + ["👦", "boy", 1], + ["👧", "girl", 1], + ["🧑", "adult", 1], + ["👨", "man", 1], + ["👩", "woman", 1], + ["🧑🦱", "curly_hair", 1], + ["👩🦱", "curly_hair_woman", 1], + ["👨🦱", "curly_hair_man", 1], + ["🧑🦰", "red_hair", 1], + ["👩🦰", "red_hair_woman", 1], + ["👨🦰", "red_hair_man", 1], + ["👱♀️", "blonde_woman", 1], + ["👱", "blonde_man", 1], + ["🧑🦳", "white_hair", 1], + ["👩🦳", "white_hair_woman", 1], + ["👨🦳", "white_hair_man", 1], + ["🧑🦲", "bald", 1], + ["👩🦲", "bald_woman", 1], + ["👨🦲", "bald_man", 1], + ["🧔", "bearded_person", 1], + ["🧓", "older_adult", 1], + ["👴", "older_man", 1], + ["👵", "older_woman", 1], + ["👲", "man_with_gua_pi_mao", 1], + ["🧕", "woman_with_headscarf", 1], + ["👳♀️", "woman_with_turban", 1], + ["👳", "man_with_turban", 1], + ["👮♀️", "policewoman", 1], + ["👮", "policeman", 1], + ["👷♀️", "construction_worker_woman", 1], + ["👷", "construction_worker_man", 1], + ["💂♀️", "guardswoman", 1], + ["💂", "guardsman", 1], + ["🕵️♀️", "female_detective", 1], + ["🕵", "male_detective", 1], + ["🧑⚕️", "health_worker", 1], + ["👩⚕️", "woman_health_worker", 1], + ["👨⚕️", "man_health_worker", 1], + ["🧑🌾", "farmer", 1], + ["👩🌾", "woman_farmer", 1], + ["👨🌾", "man_farmer", 1], + ["🧑🍳", "cook", 1], + ["👩🍳", "woman_cook", 1], + ["👨🍳", "man_cook", 1], + ["🧑🎓", "student", 1], + ["👩🎓", "woman_student", 1], + ["👨🎓", "man_student", 1], + ["🧑🎤", "singer", 1], + ["👩🎤", "woman_singer", 1], + ["👨🎤", "man_singer", 1], + ["🧑🏫", "teacher", 1], + ["👩🏫", "woman_teacher", 1], + ["👨🏫", "man_teacher", 1], + ["🧑🏭", "factory_worker", 1], + ["👩🏭", "woman_factory_worker", 1], + ["👨🏭", "man_factory_worker", 1], + ["🧑💻", "technologist", 1], + ["👩💻", "woman_technologist", 1], + ["👨💻", "man_technologist", 1], + ["🧑💼", "office_worker", 1], + ["👩💼", "woman_office_worker", 1], + ["👨💼", "man_office_worker", 1], + ["🧑🔧", "mechanic", 1], + ["👩🔧", "woman_mechanic", 1], + ["👨🔧", "man_mechanic", 1], + ["🧑🔬", "scientist", 1], + ["👩🔬", "woman_scientist", 1], + ["👨🔬", "man_scientist", 1], + ["🧑🎨", "artist", 1], + ["👩🎨", "woman_artist", 1], + ["👨🎨", "man_artist", 1], + ["🧑🚒", "firefighter", 1], + ["👩🚒", "woman_firefighter", 1], + ["👨🚒", "man_firefighter", 1], + ["🧑✈️", "pilot", 1], + ["👩✈️", "woman_pilot", 1], + ["👨✈️", "man_pilot", 1], + ["🧑🚀", "astronaut", 1], + ["👩🚀", "woman_astronaut", 1], + ["👨🚀", "man_astronaut", 1], + ["🧑⚖️", "judge", 1], + ["👩⚖️", "woman_judge", 1], + ["👨⚖️", "man_judge", 1], + ["🦸♀️", "woman_superhero", 1], + ["🦸♂️", "man_superhero", 1], + ["🦹♀️", "woman_supervillain", 1], + ["🦹♂️", "man_supervillain", 1], + ["🤶", "mrs_claus", 1], + ["🧑🎄", "mx_claus", 1], + ["🎅", "santa", 1], + ["🥷", "ninja", 1], + ["🧙♀️", "sorceress", 1], + ["🧙♂️", "wizard", 1], + ["🧝♀️", "woman_elf", 1], + ["🧝♂️", "man_elf", 1], + ["🧛♀️", "woman_vampire", 1], + ["🧛♂️", "man_vampire", 1], + ["🧟♀️", "woman_zombie", 1], + ["🧟♂️", "man_zombie", 1], + ["🧞♀️", "woman_genie", 1], + ["🧞♂️", "man_genie", 1], + ["🧜♀️", "mermaid", 1], + ["🧜♂️", "merman", 1], + ["🧚♀️", "woman_fairy", 1], + ["🧚♂️", "man_fairy", 1], + ["👼", "angel", 1], + ["🧌", "troll", 1], + ["🤰", "pregnant_woman", 1], + ["🫃", "pregnant_man", 1], + ["🫄", "pregnant_person", 1], + ["🫅", "person_with_crown", 1], + ["🤱", "breastfeeding", 1], + ["👩🍼", "woman_feeding_baby", 1], + ["👨🍼", "man_feeding_baby", 1], + ["🧑🍼", "person_feeding_baby", 1], + ["👸", "princess", 1], + ["🤴", "prince", 1], + ["👰", "person_with_veil", 1], + ["👰", "bride_with_veil", 1], + ["🤵", "person_in_tuxedo", 1], + ["🤵", "man_in_tuxedo", 1], + ["🏃♀️", "running_woman", 1], + ["🏃", "running_man", 1], + ["🚶♀️", "walking_woman", 1], + ["🚶", "walking_man", 1], + ["💃", "dancer", 1], + ["🕺", "man_dancing", 1], + ["👯", "dancing_women", 1], + ["👯♂️", "dancing_men", 1], + ["👫", "couple", 1], + ["🧑🤝🧑", "people_holding_hands", 1], + ["👬", "two_men_holding_hands", 1], + ["👭", "two_women_holding_hands", 1], + ["🫂", "people_hugging", 1], + ["🙇♀️", "bowing_woman", 1], + ["🙇", "bowing_man", 1], + ["🤦♂️", "man_facepalming", 1], + ["🤦♀️", "woman_facepalming", 1], + ["🤷", "woman_shrugging", 1], + ["🤷♂️", "man_shrugging", 1], + ["💁", "tipping_hand_woman", 1], + ["💁♂️", "tipping_hand_man", 1], + ["🙅", "no_good_woman", 1], + ["🙅♂️", "no_good_man", 1], + ["🙆", "ok_woman", 1], + ["🙆♂️", "ok_man", 1], + ["🙋", "raising_hand_woman", 1], + ["🙋♂️", "raising_hand_man", 1], + ["🙎", "pouting_woman", 1], + ["🙎♂️", "pouting_man", 1], + ["🙍", "frowning_woman", 1], + ["🙍♂️", "frowning_man", 1], + ["💇", "haircut_woman", 1], + ["💇♂️", "haircut_man", 1], + ["💆", "massage_woman", 1], + ["💆♂️", "massage_man", 1], + ["🧖♀️", "woman_in_steamy_room", 1], + ["🧖♂️", "man_in_steamy_room", 1], + ["🧏♀️", "woman_deaf", 1], + ["🧏♂️", "man_deaf", 1], + ["🧍♀️", "woman_standing", 1], + ["🧍♂️", "man_standing", 1], + ["🧎♀️", "woman_kneeling", 1], + ["🧎♂️", "man_kneeling", 1], + ["🧑🦯", "person_with_probing_cane", 1], + ["👩🦯", "woman_with_probing_cane", 1], + ["👨🦯", "man_with_probing_cane", 1], + ["🧑🦼", "person_in_motorized_wheelchair", 1], + ["👩🦼", "woman_in_motorized_wheelchair", 1], + ["👨🦼", "man_in_motorized_wheelchair", 1], + ["🧑🦽", "person_in_manual_wheelchair", 1], + ["👩🦽", "woman_in_manual_wheelchair", 1], + ["👨🦽", "man_in_manual_wheelchair", 1], + ["💑", "couple_with_heart_woman_man", 1], + ["👩❤️👩", "couple_with_heart_woman_woman", 1], + ["👨❤️👨", "couple_with_heart_man_man", 1], + ["💏", "couplekiss_man_woman", 1], + ["👩❤️💋👩", "couplekiss_woman_woman", 1], + ["👨❤️💋👨", "couplekiss_man_man", 1], + ["👪", "family_man_woman_boy", 1], + ["👨👩👧", "family_man_woman_girl", 1], + ["👨👩👧👦", "family_man_woman_girl_boy", 1], + ["👨👩👦👦", "family_man_woman_boy_boy", 1], + ["👨👩👧👧", "family_man_woman_girl_girl", 1], + ["👩👩👦", "family_woman_woman_boy", 1], + ["👩👩👧", "family_woman_woman_girl", 1], + ["👩👩👧👦", "family_woman_woman_girl_boy", 1], + ["👩👩👦👦", "family_woman_woman_boy_boy", 1], + ["👩👩👧👧", "family_woman_woman_girl_girl", 1], + ["👨👨👦", "family_man_man_boy", 1], + ["👨👨👧", "family_man_man_girl", 1], + ["👨👨👧👦", "family_man_man_girl_boy", 1], + ["👨👨👦👦", "family_man_man_boy_boy", 1], + ["👨👨👧👧", "family_man_man_girl_girl", 1], + ["👩👦", "family_woman_boy", 1], + ["👩👧", "family_woman_girl", 1], + ["👩👧👦", "family_woman_girl_boy", 1], + ["👩👦👦", "family_woman_boy_boy", 1], + ["👩👧👧", "family_woman_girl_girl", 1], + ["👨👦", "family_man_boy", 1], + ["👨👧", "family_man_girl", 1], + ["👨👧👦", "family_man_girl_boy", 1], + ["👨👦👦", "family_man_boy_boy", 1], + ["👨👧👧", "family_man_girl_girl", 1], + ["🧶", "yarn", 1], + ["🧵", "thread", 1], + ["🧥", "coat", 1], + ["🥼", "labcoat", 1], + ["👚", "womans_clothes", 1], + ["👕", "tshirt", 1], + ["👖", "jeans", 1], + ["👔", "necktie", 1], + ["👗", "dress", 1], + ["👙", "bikini", 1], + ["🩱", "one_piece_swimsuit", 1], + ["👘", "kimono", 1], + ["🥻", "sari", 1], + ["🩲", "briefs", 1], + ["🩳", "shorts", 1], + ["💄", "lipstick", 1], + ["💋", "kiss", 1], + ["👣", "footprints", 1], + ["🥿", "flat_shoe", 1], + ["👠", "high_heel", 1], + ["👡", "sandal", 1], + ["👢", "boot", 1], + ["👞", "mans_shoe", 1], + ["👟", "athletic_shoe", 1], + ["🩴", "thong_sandal", 1], + ["🩰", "ballet_shoes", 1], + ["🧦", "socks", 1], + ["🧤", "gloves", 1], + ["🧣", "scarf", 1], + ["👒", "womans_hat", 1], + ["🎩", "tophat", 1], + ["🧢", "billed_hat", 1], + ["⛑", "rescue_worker_helmet", 1], + ["🪖", "military_helmet", 1], + ["🎓", "mortar_board", 1], + ["👑", "crown", 1], + ["🎒", "school_satchel", 1], + ["🧳", "luggage", 1], + ["👝", "pouch", 1], + ["👛", "purse", 1], + ["👜", "handbag", 1], + ["💼", "briefcase", 1], + ["👓", "eyeglasses", 1], + ["🕶", "dark_sunglasses", 1], + ["🥽", "goggles", 1], + ["💍", "ring", 1], + ["🌂", "closed_umbrella", 1], + ["🐶", "dog", 2], + ["🐱", "cat", 2], + ["🐈⬛", "black_cat", 2], + ["🐭", "mouse", 2], + ["🐹", "hamster", 2], + ["🐰", "rabbit", 2], + ["🦊", "fox_face", 2], + ["🐻", "bear", 2], + ["🐼", "panda_face", 2], + ["🐨", "koala", 2], + ["🐯", "tiger", 2], + ["🦁", "lion", 2], + ["🐮", "cow", 2], + ["🐷", "pig", 2], + ["🐽", "pig_nose", 2], + ["🐸", "frog", 2], + ["🦑", "squid", 2], + ["🐙", "octopus", 2], + ["🦐", "shrimp", 2], + ["🐵", "monkey_face", 2], + ["🦍", "gorilla", 2], + ["🙈", "see_no_evil", 2], + ["🙉", "hear_no_evil", 2], + ["🙊", "speak_no_evil", 2], + ["🐒", "monkey", 2], + ["🐔", "chicken", 2], + ["🐧", "penguin", 2], + ["🐦", "bird", 2], + ["🐤", "baby_chick", 2], + ["🐣", "hatching_chick", 2], + ["🐥", "hatched_chick", 2], + ["🦆", "duck", 2], + ["🦅", "eagle", 2], + ["🦉", "owl", 2], + ["🦇", "bat", 2], + ["🐺", "wolf", 2], + ["🐗", "boar", 2], + ["🐴", "horse", 2], + ["🦄", "unicorn", 2], + ["🐝", "honeybee", 2], + ["🐛", "bug", 2], + ["🦋", "butterfly", 2], + ["🐌", "snail", 2], + ["🐞", "lady_beetle", 2], + ["🐜", "ant", 2], + ["🦗", "grasshopper", 2], + ["🕷", "spider", 2], + ["🪲", "beetle", 2], + ["🪳", "cockroach", 2], + ["🪰", "fly", 2], + ["🪱", "worm", 2], + ["🦂", "scorpion", 2], + ["🦀", "crab", 2], + ["🐍", "snake", 2], + ["🦎", "lizard", 2], + ["🦖", "t-rex", 2], + ["🦕", "sauropod", 2], + ["🐢", "turtle", 2], + ["🐠", "tropical_fish", 2], + ["🐟", "fish", 2], + ["🐡", "blowfish", 2], + ["🐬", "dolphin", 2], + ["🦈", "shark", 2], + ["🐳", "whale", 2], + ["🐋", "whale2", 2], + ["🐊", "crocodile", 2], + ["🐆", "leopard", 2], + ["🦓", "zebra", 2], + ["🐅", "tiger2", 2], + ["🐃", "water_buffalo", 2], + ["🐂", "ox", 2], + ["🐄", "cow2", 2], + ["🦌", "deer", 2], + ["🐪", "dromedary_camel", 2], + ["🐫", "camel", 2], + ["🦒", "giraffe", 2], + ["🐘", "elephant", 2], + ["🦏", "rhinoceros", 2], + ["🐐", "goat", 2], + ["🐏", "ram", 2], + ["🐑", "sheep", 2], + ["🐎", "racehorse", 2], + ["🐖", "pig2", 2], + ["🐀", "rat", 2], + ["🐁", "mouse2", 2], + ["🐓", "rooster", 2], + ["🦃", "turkey", 2], + ["🕊", "dove", 2], + ["🐕", "dog2", 2], + ["🐩", "poodle", 2], + ["🐈", "cat2", 2], + ["🐇", "rabbit2", 2], + ["🐿", "chipmunk", 2], + ["🦔", "hedgehog", 2], + ["🦝", "raccoon", 2], + ["🦙", "llama", 2], + ["🦛", "hippopotamus", 2], + ["🦘", "kangaroo", 2], + ["🦡", "badger", 2], + ["🦢", "swan", 2], + ["🦚", "peacock", 2], + ["🦜", "parrot", 2], + ["🦞", "lobster", 2], + ["🦠", "microbe", 2], + ["🦟", "mosquito", 2], + ["🦬", "bison", 2], + ["🦣", "mammoth", 2], + ["🦫", "beaver", 2], + ["🐻❄️", "polar_bear", 2], + ["🦤", "dodo", 2], + ["🪶", "feather", 2], + ["🦭", "seal", 2], + ["🐾", "paw_prints", 2], + ["🐉", "dragon", 2], + ["🐲", "dragon_face", 2], + ["🦧", "orangutan", 2], + ["🦮", "guide_dog", 2], + ["🐕🦺", "service_dog", 2], + ["🦥", "sloth", 2], + ["🦦", "otter", 2], + ["🦨", "skunk", 2], + ["🦩", "flamingo", 2], + ["🌵", "cactus", 2], + ["🎄", "christmas_tree", 2], + ["🌲", "evergreen_tree", 2], + ["🌳", "deciduous_tree", 2], + ["🌴", "palm_tree", 2], + ["🌱", "seedling", 2], + ["🌿", "herb", 2], + ["☘", "shamrock", 2], + ["🍀", "four_leaf_clover", 2], + ["🎍", "bamboo", 2], + ["🎋", "tanabata_tree", 2], + ["🍃", "leaves", 2], + ["🍂", "fallen_leaf", 2], + ["🍁", "maple_leaf", 2], + ["🌾", "ear_of_rice", 2], + ["🌺", "hibiscus", 2], + ["🌻", "sunflower", 2], + ["🌹", "rose", 2], + ["🥀", "wilted_flower", 2], + ["🌷", "tulip", 2], + ["🌼", "blossom", 2], + ["🌸", "cherry_blossom", 2], + ["💐", "bouquet", 2], + ["🍄", "mushroom", 2], + ["🪴", "potted_plant", 2], + ["🌰", "chestnut", 2], + ["🎃", "jack_o_lantern", 2], + ["🐚", "shell", 2], + ["🕸", "spider_web", 2], + ["🌎", "earth_americas", 2], + ["🌍", "earth_africa", 2], + ["🌏", "earth_asia", 2], + ["🪐", "ringed_planet", 2], + ["🌕", "full_moon", 2], + ["🌖", "waning_gibbous_moon", 2], + ["🌗", "last_quarter_moon", 2], + ["🌘", "waning_crescent_moon", 2], + ["🌑", "new_moon", 2], + ["🌒", "waxing_crescent_moon", 2], + ["🌓", "first_quarter_moon", 2], + ["🌔", "waxing_gibbous_moon", 2], + ["🌚", "new_moon_with_face", 2], + ["🌝", "full_moon_with_face", 2], + ["🌛", "first_quarter_moon_with_face", 2], + ["🌜", "last_quarter_moon_with_face", 2], + ["🌞", "sun_with_face", 2], + ["🌙", "crescent_moon", 2], + ["⭐", "star", 2], + ["🌟", "star2", 2], + ["💫", "dizzy", 2], + ["✨", "sparkles", 2], + ["☄", "comet", 2], + ["☀️", "sunny", 2], + ["🌤", "sun_behind_small_cloud", 2], + ["⛅", "partly_sunny", 2], + ["🌥", "sun_behind_large_cloud", 2], + ["🌦", "sun_behind_rain_cloud", 2], + ["☁️", "cloud", 2], + ["🌧", "cloud_with_rain", 2], + ["⛈", "cloud_with_lightning_and_rain", 2], + ["🌩", "cloud_with_lightning", 2], + ["⚡", "zap", 2], + ["🔥", "fire", 2], + ["💥", "boom", 2], + ["❄️", "snowflake", 2], + ["🌨", "cloud_with_snow", 2], + ["⛄", "snowman", 2], + ["☃", "snowman_with_snow", 2], + ["🌬", "wind_face", 2], + ["💨", "dash", 2], + ["🌪", "tornado", 2], + ["🌫", "fog", 2], + ["☂", "open_umbrella", 2], + ["☔", "umbrella", 2], + ["💧", "droplet", 2], + ["💦", "sweat_drops", 2], + ["🌊", "ocean", 2], + ["🪷", "lotus", 2], + ["🪸", "coral", 2], + ["🪹", "empty_nest", 2], + ["🪺", "nest_with_eggs", 2], + ["🍏", "green_apple", 3], + ["🍎", "apple", 3], + ["🍐", "pear", 3], + ["🍊", "tangerine", 3], + ["🍋", "lemon", 3], + ["🍌", "banana", 3], + ["🍉", "watermelon", 3], + ["🍇", "grapes", 3], + ["🍓", "strawberry", 3], + ["🍈", "melon", 3], + ["🍒", "cherries", 3], + ["🍑", "peach", 3], + ["🍍", "pineapple", 3], + ["🥥", "coconut", 3], + ["🥝", "kiwi_fruit", 3], + ["🥭", "mango", 3], + ["🥑", "avocado", 3], + ["🥦", "broccoli", 3], + ["🍅", "tomato", 3], + ["🍆", "eggplant", 3], + ["🥒", "cucumber", 3], + ["🫐", "blueberries", 3], + ["🫒", "olive", 3], + ["🫑", "bell_pepper", 3], + ["🥕", "carrot", 3], + ["🌶", "hot_pepper", 3], + ["🥔", "potato", 3], + ["🌽", "corn", 3], + ["🥬", "leafy_greens", 3], + ["🍠", "sweet_potato", 3], + ["🥜", "peanuts", 3], + ["🧄", "garlic", 3], + ["🧅", "onion", 3], + ["🍯", "honey_pot", 3], + ["🥐", "croissant", 3], + ["🍞", "bread", 3], + ["🥖", "baguette_bread", 3], + ["🥯", "bagel", 3], + ["🥨", "pretzel", 3], + ["🧀", "cheese", 3], + ["🥚", "egg", 3], + ["🥓", "bacon", 3], + ["🥩", "steak", 3], + ["🥞", "pancakes", 3], + ["🍗", "poultry_leg", 3], + ["🍖", "meat_on_bone", 3], + ["🦴", "bone", 3], + ["🍤", "fried_shrimp", 3], + ["🍳", "fried_egg", 3], + ["🍔", "hamburger", 3], + ["🍟", "fries", 3], + ["🥙", "stuffed_flatbread", 3], + ["🌭", "hotdog", 3], + ["🍕", "pizza", 3], + ["🥪", "sandwich", 3], + ["🥫", "canned_food", 3], + ["🍝", "spaghetti", 3], + ["🌮", "taco", 3], + ["🌯", "burrito", 3], + ["🥗", "green_salad", 3], + ["🥘", "shallow_pan_of_food", 3], + ["🍜", "ramen", 3], + ["🍲", "stew", 3], + ["🍥", "fish_cake", 3], + ["🥠", "fortune_cookie", 3], + ["🍣", "sushi", 3], + ["🍱", "bento", 3], + ["🍛", "curry", 3], + ["🍙", "rice_ball", 3], + ["🍚", "rice", 3], + ["🍘", "rice_cracker", 3], + ["🍢", "oden", 3], + ["🍡", "dango", 3], + ["🍧", "shaved_ice", 3], + ["🍨", "ice_cream", 3], + ["🍦", "icecream", 3], + ["🥧", "pie", 3], + ["🍰", "cake", 3], + ["🧁", "cupcake", 3], + ["🥮", "moon_cake", 3], + ["🎂", "birthday", 3], + ["🍮", "custard", 3], + ["🍬", "candy", 3], + ["🍭", "lollipop", 3], + ["🍫", "chocolate_bar", 3], + ["🍿", "popcorn", 3], + ["🥟", "dumpling", 3], + ["🍩", "doughnut", 3], + ["🍪", "cookie", 3], + ["🧇", "waffle", 3], + ["🧆", "falafel", 3], + ["🧈", "butter", 3], + ["🦪", "oyster", 3], + ["🫓", "flatbread", 3], + ["🫔", "tamale", 3], + ["🫕", "fondue", 3], + ["🥛", "milk_glass", 3], + ["🍺", "beer", 3], + ["🍻", "beers", 3], + ["🥂", "clinking_glasses", 3], + ["🍷", "wine_glass", 3], + ["🥃", "tumbler_glass", 3], + ["🍸", "cocktail", 3], + ["🍹", "tropical_drink", 3], + ["🍾", "champagne", 3], + ["🍶", "sake", 3], + ["🍵", "tea", 3], + ["🥤", "cup_with_straw", 3], + ["☕", "coffee", 3], + ["🫖", "teapot", 3], + ["🧋", "bubble_tea", 3], + ["🍼", "baby_bottle", 3], + ["🧃", "beverage_box", 3], + ["🧉", "mate", 3], + ["🧊", "ice_cube", 3], + ["🧂", "salt", 3], + ["🥄", "spoon", 3], + ["🍴", "fork_and_knife", 3], + ["🍽", "plate_with_cutlery", 3], + ["🥣", "bowl_with_spoon", 3], + ["🥡", "takeout_box", 3], + ["🥢", "chopsticks", 3], + ["🫗", "pouring_liquid", 3], + ["🫘", "beans", 3], + ["🫙", "jar", 3], + ["⚽", "soccer", 4], + ["🏀", "basketball", 4], + ["🏈", "football", 4], + ["⚾", "baseball", 4], + ["🥎", "softball", 4], + ["🎾", "tennis", 4], + ["🏐", "volleyball", 4], + ["🏉", "rugby_football", 4], + ["🥏", "flying_disc", 4], + ["🎱", "8ball", 4], + ["⛳", "golf", 4], + ["🏌️♀️", "golfing_woman", 4], + ["🏌", "golfing_man", 4], + ["🏓", "ping_pong", 4], + ["🏸", "badminton", 4], + ["🥅", "goal_net", 4], + ["🏒", "ice_hockey", 4], + ["🏑", "field_hockey", 4], + ["🥍", "lacrosse", 4], + ["🏏", "cricket", 4], + ["🎿", "ski", 4], + ["⛷", "skier", 4], + ["🏂", "snowboarder", 4], + ["🤺", "person_fencing", 4], + ["🤼♀️", "women_wrestling", 4], + ["🤼♂️", "men_wrestling", 4], + ["🤸♀️", "woman_cartwheeling", 4], + ["🤸♂️", "man_cartwheeling", 4], + ["🤾♀️", "woman_playing_handball", 4], + ["🤾♂️", "man_playing_handball", 4], + ["⛸", "ice_skate", 4], + ["🥌", "curling_stone", 4], + ["🛹", "skateboard", 4], + ["🛷", "sled", 4], + ["🏹", "bow_and_arrow", 4], + ["🎣", "fishing_pole_and_fish", 4], + ["🥊", "boxing_glove", 4], + ["🥋", "martial_arts_uniform", 4], + ["🚣♀️", "rowing_woman", 4], + ["🚣", "rowing_man", 4], + ["🧗♀️", "climbing_woman", 4], + ["🧗♂️", "climbing_man", 4], + ["🏊♀️", "swimming_woman", 4], + ["🏊", "swimming_man", 4], + ["🤽♀️", "woman_playing_water_polo", 4], + ["🤽♂️", "man_playing_water_polo", 4], + ["🧘♀️", "woman_in_lotus_position", 4], + ["🧘♂️", "man_in_lotus_position", 4], + ["🏄♀️", "surfing_woman", 4], + ["🏄", "surfing_man", 4], + ["🛀", "bath", 4], + ["⛹️♀️", "basketball_woman", 4], + ["⛹", "basketball_man", 4], + ["🏋️♀️", "weight_lifting_woman", 4], + ["🏋", "weight_lifting_man", 4], + ["🚴♀️", "biking_woman", 4], + ["🚴", "biking_man", 4], + ["🚵♀️", "mountain_biking_woman", 4], + ["🚵", "mountain_biking_man", 4], + ["🏇", "horse_racing", 4], + ["🤿", "diving_mask", 4], + ["🪀", "yo_yo", 4], + ["🪁", "kite", 4], + ["🦺", "safety_vest", 4], + ["🪡", "sewing_needle", 4], + ["🪢", "knot", 4], + ["🕴", "business_suit_levitating", 4], + ["🏆", "trophy", 4], + ["🎽", "running_shirt_with_sash", 4], + ["🏅", "medal_sports", 4], + ["🎖", "medal_military", 4], + ["🥇", "1st_place_medal", 4], + ["🥈", "2nd_place_medal", 4], + ["🥉", "3rd_place_medal", 4], + ["🎗", "reminder_ribbon", 4], + ["🏵", "rosette", 4], + ["🎫", "ticket", 4], + ["🎟", "tickets", 4], + ["🎭", "performing_arts", 4], + ["🎨", "art", 4], + ["🎪", "circus_tent", 4], + ["🤹♀️", "woman_juggling", 4], + ["🤹♂️", "man_juggling", 4], + ["🎤", "microphone", 4], + ["🎧", "headphones", 4], + ["🎼", "musical_score", 4], + ["🎹", "musical_keyboard", 4], + ["🥁", "drum", 4], + ["🎷", "saxophone", 4], + ["🎺", "trumpet", 4], + ["🎸", "guitar", 4], + ["🎻", "violin", 4], + ["🪕", "banjo", 4], + ["🪗", "accordion", 4], + ["🪘", "long_drum", 4], + ["🎬", "clapper", 4], + ["🎮", "video_game", 4], + ["👾", "space_invader", 4], + ["🎯", "dart", 4], + ["🎲", "game_die", 4], + ["♟️", "chess_pawn", 4], + ["🎰", "slot_machine", 4], + ["🧩", "jigsaw", 4], + ["🎳", "bowling", 4], + ["🪄", "magic_wand", 4], + ["🪅", "pinata", 4], + ["🪆", "nesting_dolls", 4], + ["🪬", "hamsa", 4], + ["🪩", "mirror_ball", 4], + ["🚗", "red_car", 5], + ["🚕", "taxi", 5], + ["🚙", "blue_car", 5], + ["🚌", "bus", 5], + ["🚎", "trolleybus", 5], + ["🏎", "racing_car", 5], + ["🚓", "police_car", 5], + ["🚑", "ambulance", 5], + ["🚒", "fire_engine", 5], + ["🚐", "minibus", 5], + ["🚚", "truck", 5], + ["🚛", "articulated_lorry", 5], + ["🚜", "tractor", 5], + ["🛴", "kick_scooter", 5], + ["🏍", "motorcycle", 5], + ["🚲", "bike", 5], + ["🛵", "motor_scooter", 5], + ["🦽", "manual_wheelchair", 5], + ["🦼", "motorized_wheelchair", 5], + ["🛺", "auto_rickshaw", 5], + ["🪂", "parachute", 5], + ["🚨", "rotating_light", 5], + ["🚔", "oncoming_police_car", 5], + ["🚍", "oncoming_bus", 5], + ["🚘", "oncoming_automobile", 5], + ["🚖", "oncoming_taxi", 5], + ["🚡", "aerial_tramway", 5], + ["🚠", "mountain_cableway", 5], + ["🚟", "suspension_railway", 5], + ["🚃", "railway_car", 5], + ["🚋", "train", 5], + ["🚝", "monorail", 5], + ["🚄", "bullettrain_side", 5], + ["🚅", "bullettrain_front", 5], + ["🚈", "light_rail", 5], + ["🚞", "mountain_railway", 5], + ["🚂", "steam_locomotive", 5], + ["🚆", "train2", 5], + ["🚇", "metro", 5], + ["🚊", "tram", 5], + ["🚉", "station", 5], + ["🛸", "flying_saucer", 5], + ["🚁", "helicopter", 5], + ["🛩", "small_airplane", 5], + ["✈️", "airplane", 5], + ["🛫", "flight_departure", 5], + ["🛬", "flight_arrival", 5], + ["⛵", "sailboat", 5], + ["🛥", "motor_boat", 5], + ["🚤", "speedboat", 5], + ["⛴", "ferry", 5], + ["🛳", "passenger_ship", 5], + ["🚀", "rocket", 5], + ["🛰", "artificial_satellite", 5], + ["🛻", "pickup_truck", 5], + ["🛼", "roller_skate", 5], + ["💺", "seat", 5], + ["🛶", "canoe", 5], + ["⚓", "anchor", 5], + ["🚧", "construction", 5], + ["⛽", "fuelpump", 5], + ["🚏", "busstop", 5], + ["🚦", "vertical_traffic_light", 5], + ["🚥", "traffic_light", 5], + ["🏁", "checkered_flag", 5], + ["🚢", "ship", 5], + ["🎡", "ferris_wheel", 5], + ["🎢", "roller_coaster", 5], + ["🎠", "carousel_horse", 5], + ["🏗", "building_construction", 5], + ["🌁", "foggy", 5], + ["🏭", "factory", 5], + ["⛲", "fountain", 5], + ["🎑", "rice_scene", 5], + ["⛰", "mountain", 5], + ["🏔", "mountain_snow", 5], + ["🗻", "mount_fuji", 5], + ["🌋", "volcano", 5], + ["🗾", "japan", 5], + ["🏕", "camping", 5], + ["⛺", "tent", 5], + ["🏞", "national_park", 5], + ["🛣", "motorway", 5], + ["🛤", "railway_track", 5], + ["🌅", "sunrise", 5], + ["🌄", "sunrise_over_mountains", 5], + ["🏜", "desert", 5], + ["🏖", "beach_umbrella", 5], + ["🏝", "desert_island", 5], + ["🌇", "city_sunrise", 5], + ["🌆", "city_sunset", 5], + ["🏙", "cityscape", 5], + ["🌃", "night_with_stars", 5], + ["🌉", "bridge_at_night", 5], + ["🌌", "milky_way", 5], + ["🌠", "stars", 5], + ["🎇", "sparkler", 5], + ["🎆", "fireworks", 5], + ["🌈", "rainbow", 5], + ["🏘", "houses", 5], + ["🏰", "european_castle", 5], + ["🏯", "japanese_castle", 5], + ["🗼", "tokyo_tower", 5], + ["", "shibuya_109", 5], + ["🏟", "stadium", 5], + ["🗽", "statue_of_liberty", 5], + ["🏠", "house", 5], + ["🏡", "house_with_garden", 5], + ["🏚", "derelict_house", 5], + ["🏢", "office", 5], + ["🏬", "department_store", 5], + ["🏣", "post_office", 5], + ["🏤", "european_post_office", 5], + ["🏥", "hospital", 5], + ["🏦", "bank", 5], + ["🏨", "hotel", 5], + ["🏪", "convenience_store", 5], + ["🏫", "school", 5], + ["🏩", "love_hotel", 5], + ["💒", "wedding", 5], + ["🏛", "classical_building", 5], + ["⛪", "church", 5], + ["🕌", "mosque", 5], + ["🕍", "synagogue", 5], + ["🕋", "kaaba", 5], + ["⛩", "shinto_shrine", 5], + ["🛕", "hindu_temple", 5], + ["🪨", "rock", 5], + ["🪵", "wood", 5], + ["🛖", "hut", 5], + ["🛝", "playground_slide", 5], + ["🛞", "wheel", 5], + ["🛟", "ring_buoy", 5], + ["⌚", "watch", 6], + ["📱", "iphone", 6], + ["📲", "calling", 6], + ["💻", "computer", 6], + ["⌨", "keyboard", 6], + ["🖥", "desktop_computer", 6], + ["🖨", "printer", 6], + ["🖱", "computer_mouse", 6], + ["🖲", "trackball", 6], + ["🕹", "joystick", 6], + ["🗜", "clamp", 6], + ["💽", "minidisc", 6], + ["💾", "floppy_disk", 6], + ["💿", "cd", 6], + ["📀", "dvd", 6], + ["📼", "vhs", 6], + ["📷", "camera", 6], + ["📸", "camera_flash", 6], + ["📹", "video_camera", 6], + ["🎥", "movie_camera", 6], + ["📽", "film_projector", 6], + ["🎞", "film_strip", 6], + ["📞", "telephone_receiver", 6], + ["☎️", "phone", 6], + ["📟", "pager", 6], + ["📠", "fax", 6], + ["📺", "tv", 6], + ["📻", "radio", 6], + ["🎙", "studio_microphone", 6], + ["🎚", "level_slider", 6], + ["🎛", "control_knobs", 6], + ["🧭", "compass", 6], + ["⏱", "stopwatch", 6], + ["⏲", "timer_clock", 6], + ["⏰", "alarm_clock", 6], + ["🕰", "mantelpiece_clock", 6], + ["⏳", "hourglass_flowing_sand", 6], + ["⌛", "hourglass", 6], + ["📡", "satellite", 6], + ["🔋", "battery", 6], + ["🪫", "battery", 6], + ["🔌", "electric_plug", 6], + ["💡", "bulb", 6], + ["🔦", "flashlight", 6], + ["🕯", "candle", 6], + ["🧯", "fire_extinguisher", 6], + ["🗑", "wastebasket", 6], + ["🛢", "oil_drum", 6], + ["💸", "money_with_wings", 6], + ["💵", "dollar", 6], + ["💴", "yen", 6], + ["💶", "euro", 6], + ["💷", "pound", 6], + ["💰", "moneybag", 6], + ["🪙", "coin", 6], + ["💳", "credit_card", 6], + ["🪫", "identification_card", 6], + ["💎", "gem", 6], + ["⚖", "balance_scale", 6], + ["🧰", "toolbox", 6], + ["🔧", "wrench", 6], + ["🔨", "hammer", 6], + ["⚒", "hammer_and_pick", 6], + ["🛠", "hammer_and_wrench", 6], + ["⛏", "pick", 6], + ["🪓", "axe", 6], + ["🦯", "probing_cane", 6], + ["🔩", "nut_and_bolt", 6], + ["⚙", "gear", 6], + ["🪃", "boomerang", 6], + ["🪚", "carpentry_saw", 6], + ["🪛", "screwdriver", 6], + ["🪝", "hook", 6], + ["🪜", "ladder", 6], + ["🧱", "brick", 6], + ["⛓", "chains", 6], + ["🧲", "magnet", 6], + ["🔫", "gun", 6], + ["💣", "bomb", 6], + ["🧨", "firecracker", 6], + ["🔪", "hocho", 6], + ["🗡", "dagger", 6], + ["⚔", "crossed_swords", 6], + ["🛡", "shield", 6], + ["🚬", "smoking", 6], + ["☠", "skull_and_crossbones", 6], + ["⚰", "coffin", 6], + ["⚱", "funeral_urn", 6], + ["🏺", "amphora", 6], + ["🔮", "crystal_ball", 6], + ["📿", "prayer_beads", 6], + ["🧿", "nazar_amulet", 6], + ["💈", "barber", 6], + ["⚗", "alembic", 6], + ["🔭", "telescope", 6], + ["🔬", "microscope", 6], + ["🕳", "hole", 6], + ["💊", "pill", 6], + ["💉", "syringe", 6], + ["🩸", "drop_of_blood", 6], + ["🩹", "adhesive_bandage", 6], + ["🩺", "stethoscope", 6], + ["🪒", "razor", 6], + ["🩻", "xray", 6], + ["🩼", "crutch", 6], + ["🧬", "dna", 6], + ["🧫", "petri_dish", 6], + ["🧪", "test_tube", 6], + ["🌡", "thermometer", 6], + ["🧹", "broom", 6], + ["🧺", "basket", 6], + ["🧻", "toilet_paper", 6], + ["🏷", "label", 6], + ["🔖", "bookmark", 6], + ["🚽", "toilet", 6], + ["🚿", "shower", 6], + ["🛁", "bathtub", 6], + ["🧼", "soap", 6], + ["🧽", "sponge", 6], + ["🧴", "lotion_bottle", 6], + ["🔑", "key", 6], + ["🗝", "old_key", 6], + ["🛋", "couch_and_lamp", 6], + ["🪔", "diya_Lamp", 6], + ["🛌", "sleeping_bed", 6], + ["🛏", "bed", 6], + ["🚪", "door", 6], + ["🪑", "chair", 6], + ["🛎", "bellhop_bell", 6], + ["🧸", "teddy_bear", 6], + ["🖼", "framed_picture", 6], + ["🗺", "world_map", 6], + ["🛗", "elevator", 6], + ["🪞", "mirror", 6], + ["🪟", "window", 6], + ["🪠", "plunger", 6], + ["🪤", "mouse_trap", 6], + ["🪣", "bucket", 6], + ["🪥", "toothbrush", 6], + ["🫧", "bubbles", 6], + ["⛱", "parasol_on_ground", 6], + ["🗿", "moyai", 6], + ["🛍", "shopping", 6], + ["🛒", "shopping_cart", 6], + ["🎈", "balloon", 6], + ["🎏", "flags", 6], + ["🎀", "ribbon", 6], + ["🎁", "gift", 6], + ["🎊", "confetti_ball", 6], + ["🎉", "tada", 6], + ["🎎", "dolls", 6], + ["🎐", "wind_chime", 6], + ["🎌", "crossed_flags", 6], + ["🏮", "izakaya_lantern", 6], + ["🧧", "red_envelope", 6], + ["✉️", "email", 6], + ["📩", "envelope_with_arrow", 6], + ["📨", "incoming_envelope", 6], + ["📧", "e-mail", 6], + ["💌", "love_letter", 6], + ["📮", "postbox", 6], + ["📪", "mailbox_closed", 6], + ["📫", "mailbox", 6], + ["📬", "mailbox_with_mail", 6], + ["📭", "mailbox_with_no_mail", 6], + ["📦", "package", 6], + ["📯", "postal_horn", 6], + ["📥", "inbox_tray", 6], + ["📤", "outbox_tray", 6], + ["📜", "scroll", 6], + ["📃", "page_with_curl", 6], + ["📑", "bookmark_tabs", 6], + ["🧾", "receipt", 6], + ["📊", "bar_chart", 6], + ["📈", "chart_with_upwards_trend", 6], + ["📉", "chart_with_downwards_trend", 6], + ["📄", "page_facing_up", 6], + ["📅", "date", 6], + ["📆", "calendar", 6], + ["🗓", "spiral_calendar", 6], + ["📇", "card_index", 6], + ["🗃", "card_file_box", 6], + ["🗳", "ballot_box", 6], + ["🗄", "file_cabinet", 6], + ["📋", "clipboard", 6], + ["🗒", "spiral_notepad", 6], + ["📁", "file_folder", 6], + ["📂", "open_file_folder", 6], + ["🗂", "card_index_dividers", 6], + ["🗞", "newspaper_roll", 6], + ["📰", "newspaper", 6], + ["📓", "notebook", 6], + ["📕", "closed_book", 6], + ["📗", "green_book", 6], + ["📘", "blue_book", 6], + ["📙", "orange_book", 6], + ["📔", "notebook_with_decorative_cover", 6], + ["📒", "ledger", 6], + ["📚", "books", 6], + ["📖", "open_book", 6], + ["🧷", "safety_pin", 6], + ["🔗", "link", 6], + ["📎", "paperclip", 6], + ["🖇", "paperclips", 6], + ["✂️", "scissors", 6], + ["📐", "triangular_ruler", 6], + ["📏", "straight_ruler", 6], + ["🧮", "abacus", 6], + ["📌", "pushpin", 6], + ["📍", "round_pushpin", 6], + ["🚩", "triangular_flag_on_post", 6], + ["🏳", "white_flag", 6], + ["🏴", "black_flag", 6], + ["🏳️🌈", "rainbow_flag", 6], + ["🏳️⚧️", "transgender_flag", 6], + ["🔐", "closed_lock_with_key", 6], + ["🔒", "lock", 6], + ["🔓", "unlock", 6], + ["🔏", "lock_with_ink_pen", 6], + ["🖊", "pen", 6], + ["🖋", "fountain_pen", 6], + ["✒️", "black_nib", 6], + ["📝", "memo", 6], + ["✏️", "pencil2", 6], + ["🖍", "crayon", 6], + ["🖌", "paintbrush", 6], + ["🔍", "mag", 6], + ["🔎", "mag_right", 6], + ["🪦", "headstone", 6], + ["🪧", "placard", 6], + ["💯", "100", 7], + ["🔢", "1234", 7], + ["❤️", "heart", 7], + ["🧡", "orange_heart", 7], + ["💛", "yellow_heart", 7], + ["💚", "green_heart", 7], + ["💙", "blue_heart", 7], + ["💜", "purple_heart", 7], + ["🤎", "brown_heart", 7], + ["🖤", "black_heart", 7], + ["🤍", "white_heart", 7], + ["💔", "broken_heart", 7], + ["❣", "heavy_heart_exclamation", 7], + ["💕", "two_hearts", 7], + ["💞", "revolving_hearts", 7], + ["💓", "heartbeat", 7], + ["💗", "heartpulse", 7], + ["💖", "sparkling_heart", 7], + ["💘", "cupid", 7], + ["💝", "gift_heart", 7], + ["💟", "heart_decoration", 7], + ["❤️🔥", "heart_on_fire", 7], + ["❤️🩹", "mending_heart", 7], + ["☮", "peace_symbol", 7], + ["✝", "latin_cross", 7], + ["☪", "star_and_crescent", 7], + ["🕉", "om", 7], + ["☸", "wheel_of_dharma", 7], + ["✡", "star_of_david", 7], + ["🔯", "six_pointed_star", 7], + ["🕎", "menorah", 7], + ["☯", "yin_yang", 7], + ["☦", "orthodox_cross", 7], + ["🛐", "place_of_worship", 7], + ["⛎", "ophiuchus", 7], + ["♈", "aries", 7], + ["♉", "taurus", 7], + ["♊", "gemini", 7], + ["♋", "cancer", 7], + ["♌", "leo", 7], + ["♍", "virgo", 7], + ["♎", "libra", 7], + ["♏", "scorpius", 7], + ["♐", "sagittarius", 7], + ["♑", "capricorn", 7], + ["♒", "aquarius", 7], + ["♓", "pisces", 7], + ["🆔", "id", 7], + ["⚛", "atom_symbol", 7], + ["⚧️", "transgender_symbol", 7], + ["🈳", "u7a7a", 7], + ["🈹", "u5272", 7], + ["☢", "radioactive", 7], + ["☣", "biohazard", 7], + ["📴", "mobile_phone_off", 7], + ["📳", "vibration_mode", 7], + ["🈶", "u6709", 7], + ["🈚", "u7121", 7], + ["🈸", "u7533", 7], + ["🈺", "u55b6", 7], + ["🈷️", "u6708", 7], + ["✴️", "eight_pointed_black_star", 7], + ["🆚", "vs", 7], + ["🉑", "accept", 7], + ["💮", "white_flower", 7], + ["🉐", "ideograph_advantage", 7], + ["㊙️", "secret", 7], + ["㊗️", "congratulations", 7], + ["🈴", "u5408", 7], + ["🈵", "u6e80", 7], + ["🈲", "u7981", 7], + ["🅰️", "a", 7], + ["🅱️", "b", 7], + ["🆎", "ab", 7], + ["🆑", "cl", 7], + ["🅾️", "o2", 7], + ["🆘", "sos", 7], + ["⛔", "no_entry", 7], + ["📛", "name_badge", 7], + ["🚫", "no_entry_sign", 7], + ["❌", "x", 7], + ["⭕", "o", 7], + ["🛑", "stop_sign", 7], + ["💢", "anger", 7], + ["♨️", "hotsprings", 7], + ["🚷", "no_pedestrians", 7], + ["🚯", "do_not_litter", 7], + ["🚳", "no_bicycles", 7], + ["🚱", "non-potable_water", 7], + ["🔞", "underage", 7], + ["📵", "no_mobile_phones", 7], + ["❗", "exclamation", 7], + ["❕", "grey_exclamation", 7], + ["❓", "question", 7], + ["❔", "grey_question", 7], + ["‼️", "bangbang", 7], + ["⁉️", "interrobang", 7], + ["🔅", "low_brightness", 7], + ["🔆", "high_brightness", 7], + ["🔱", "trident", 7], + ["⚜", "fleur_de_lis", 7], + ["〽️", "part_alternation_mark", 7], + ["⚠️", "warning", 7], + ["🚸", "children_crossing", 7], + ["🔰", "beginner", 7], + ["♻️", "recycle", 7], + ["🈯", "u6307", 7], + ["💹", "chart", 7], + ["❇️", "sparkle", 7], + ["✳️", "eight_spoked_asterisk", 7], + ["❎", "negative_squared_cross_mark", 7], + ["✅", "white_check_mark", 7], + ["💠", "diamond_shape_with_a_dot_inside", 7], + ["🌀", "cyclone", 7], + ["➿", "loop", 7], + ["🌐", "globe_with_meridians", 7], + ["Ⓜ️", "m", 7], + ["🏧", "atm", 7], + ["🈂️", "sa", 7], + ["🛂", "passport_control", 7], + ["🛃", "customs", 7], + ["🛄", "baggage_claim", 7], + ["🛅", "left_luggage", 7], + ["♿", "wheelchair", 7], + ["🚭", "no_smoking", 7], + ["🚾", "wc", 7], + ["🅿️", "parking", 7], + ["🚰", "potable_water", 7], + ["🚹", "mens", 7], + ["🚺", "womens", 7], + ["🚼", "baby_symbol", 7], + ["🚻", "restroom", 7], + ["🚮", "put_litter_in_its_place", 7], + ["🎦", "cinema", 7], + ["📶", "signal_strength", 7], + ["🈁", "koko", 7], + ["🆖", "ng", 7], + ["🆗", "ok", 7], + ["🆙", "up", 7], + ["🆒", "cool", 7], + ["🆕", "new", 7], + ["🆓", "free", 7], + ["0️⃣", "zero", 7], + ["1️⃣", "one", 7], + ["2️⃣", "two", 7], + ["3️⃣", "three", 7], + ["4️⃣", "four", 7], + ["5️⃣", "five", 7], + ["6️⃣", "six", 7], + ["7️⃣", "seven", 7], + ["8️⃣", "eight", 7], + ["9️⃣", "nine", 7], + ["🔟", "keycap_ten", 7], + ["*⃣", "asterisk", 7], + ["⏏️", "eject_button", 7], + ["▶️", "arrow_forward", 7], + ["⏸", "pause_button", 7], + ["⏭", "next_track_button", 7], + ["⏹", "stop_button", 7], + ["⏺", "record_button", 7], + ["⏯", "play_or_pause_button", 7], + ["⏮", "previous_track_button", 7], + ["⏩", "fast_forward", 7], + ["⏪", "rewind", 7], + ["🔀", "twisted_rightwards_arrows", 7], + ["🔁", "repeat", 7], + ["🔂", "repeat_one", 7], + ["◀️", "arrow_backward", 7], + ["🔼", "arrow_up_small", 7], + ["🔽", "arrow_down_small", 7], + ["⏫", "arrow_double_up", 7], + ["⏬", "arrow_double_down", 7], + ["➡️", "arrow_right", 7], + ["⬅️", "arrow_left", 7], + ["⬆️", "arrow_up", 7], + ["⬇️", "arrow_down", 7], + ["↗️", "arrow_upper_right", 7], + ["↘️", "arrow_lower_right", 7], + ["↙️", "arrow_lower_left", 7], + ["↖️", "arrow_upper_left", 7], + ["↕️", "arrow_up_down", 7], + ["↔️", "left_right_arrow", 7], + ["🔄", "arrows_counterclockwise", 7], + ["↪️", "arrow_right_hook", 7], + ["↩️", "leftwards_arrow_with_hook", 7], + ["⤴️", "arrow_heading_up", 7], + ["⤵️", "arrow_heading_down", 7], + ["#️⃣", "hash", 7], + ["ℹ️", "information_source", 7], + ["🔤", "abc", 7], + ["🔡", "abcd", 7], + ["🔠", "capital_abcd", 7], + ["🔣", "symbols", 7], + ["🎵", "musical_note", 7], + ["🎶", "notes", 7], + ["〰️", "wavy_dash", 7], + ["➰", "curly_loop", 7], + ["✔️", "heavy_check_mark", 7], + ["🔃", "arrows_clockwise", 7], + ["➕", "heavy_plus_sign", 7], + ["➖", "heavy_minus_sign", 7], + ["➗", "heavy_division_sign", 7], + ["✖️", "heavy_multiplication_x", 7], + ["🟰", "heavy_equals_sign", 7], + ["♾", "infinity", 7], + ["💲", "heavy_dollar_sign", 7], + ["💱", "currency_exchange", 7], + ["©️", "copyright", 7], + ["®️", "registered", 7], + ["™️", "tm", 7], + ["🔚", "end", 7], + ["🔙", "back", 7], + ["🔛", "on", 7], + ["🔝", "top", 7], + ["🔜", "soon", 7], + ["☑️", "ballot_box_with_check", 7], + ["🔘", "radio_button", 7], + ["⚫", "black_circle", 7], + ["⚪", "white_circle", 7], + ["🔴", "red_circle", 7], + ["🟠", "orange_circle", 7], + ["🟡", "yellow_circle", 7], + ["🟢", "green_circle", 7], + ["🔵", "large_blue_circle", 7], + ["🟣", "purple_circle", 7], + ["🟤", "brown_circle", 7], + ["🔸", "small_orange_diamond", 7], + ["🔹", "small_blue_diamond", 7], + ["🔶", "large_orange_diamond", 7], + ["🔷", "large_blue_diamond", 7], + ["🔺", "small_red_triangle", 7], + ["▪️", "black_small_square", 7], + ["▫️", "white_small_square", 7], + ["⬛", "black_large_square", 7], + ["⬜", "white_large_square", 7], + ["🟥", "red_square", 7], + ["🟧", "orange_square", 7], + ["🟨", "yellow_square", 7], + ["🟩", "green_square", 7], + ["🟦", "blue_square", 7], + ["🟪", "purple_square", 7], + ["🟫", "brown_square", 7], + ["🔻", "small_red_triangle_down", 7], + ["◼️", "black_medium_square", 7], + ["◻️", "white_medium_square", 7], + ["◾", "black_medium_small_square", 7], + ["◽", "white_medium_small_square", 7], + ["🔲", "black_square_button", 7], + ["🔳", "white_square_button", 7], + ["🔈", "speaker", 7], + ["🔉", "sound", 7], + ["🔊", "loud_sound", 7], + ["🔇", "mute", 7], + ["📣", "mega", 7], + ["📢", "loudspeaker", 7], + ["🔔", "bell", 7], + ["🔕", "no_bell", 7], + ["🃏", "black_joker", 7], + ["🀄", "mahjong", 7], + ["♠️", "spades", 7], + ["♣️", "clubs", 7], + ["♥️", "hearts", 7], + ["♦️", "diamonds", 7], + ["🎴", "flower_playing_cards", 7], + ["💭", "thought_balloon", 7], + ["🗯", "right_anger_bubble", 7], + ["💬", "speech_balloon", 7], + ["🗨", "left_speech_bubble", 7], + ["🕐", "clock1", 7], + ["🕑", "clock2", 7], + ["🕒", "clock3", 7], + ["🕓", "clock4", 7], + ["🕔", "clock5", 7], + ["🕕", "clock6", 7], + ["🕖", "clock7", 7], + ["🕗", "clock8", 7], + ["🕘", "clock9", 7], + ["🕙", "clock10", 7], + ["🕚", "clock11", 7], + ["🕛", "clock12", 7], + ["🕜", "clock130", 7], + ["🕝", "clock230", 7], + ["🕞", "clock330", 7], + ["🕟", "clock430", 7], + ["🕠", "clock530", 7], + ["🕡", "clock630", 7], + ["🕢", "clock730", 7], + ["🕣", "clock830", 7], + ["🕤", "clock930", 7], + ["🕥", "clock1030", 7], + ["🕦", "clock1130", 7], + ["🕧", "clock1230", 7], + ["🇦🇫", "afghanistan", 8], + ["🇦🇽", "aland_islands", 8], + ["🇦🇱", "albania", 8], + ["🇩🇿", "algeria", 8], + ["🇦🇸", "american_samoa", 8], + ["🇦🇩", "andorra", 8], + ["🇦🇴", "angola", 8], + ["🇦🇮", "anguilla", 8], + ["🇦🇶", "antarctica", 8], + ["🇦🇬", "antigua_barbuda", 8], + ["🇦🇷", "argentina", 8], + ["🇦🇲", "armenia", 8], + ["🇦🇼", "aruba", 8], + ["🇦🇨", "ascension_island", 8], + ["🇦🇺", "australia", 8], + ["🇦🇹", "austria", 8], + ["🇦🇿", "azerbaijan", 8], + ["🇧🇸", "bahamas", 8], + ["🇧🇭", "bahrain", 8], + ["🇧🇩", "bangladesh", 8], + ["🇧🇧", "barbados", 8], + ["🇧🇾", "belarus", 8], + ["🇧🇪", "belgium", 8], + ["🇧🇿", "belize", 8], + ["🇧🇯", "benin", 8], + ["🇧🇲", "bermuda", 8], + ["🇧🇹", "bhutan", 8], + ["🇧🇴", "bolivia", 8], + ["🇧🇶", "caribbean_netherlands", 8], + ["🇧🇦", "bosnia_herzegovina", 8], + ["🇧🇼", "botswana", 8], + ["🇧🇷", "brazil", 8], + ["🇮🇴", "british_indian_ocean_territory", 8], + ["🇻🇬", "british_virgin_islands", 8], + ["🇧🇳", "brunei", 8], + ["🇧🇬", "bulgaria", 8], + ["🇧🇫", "burkina_faso", 8], + ["🇧🇮", "burundi", 8], + ["🇨🇻", "cape_verde", 8], + ["🇰🇭", "cambodia", 8], + ["🇨🇲", "cameroon", 8], + ["🇨🇦", "canada", 8], + ["🇮🇨", "canary_islands", 8], + ["🇰🇾", "cayman_islands", 8], + ["🇨🇫", "central_african_republic", 8], + ["🇹🇩", "chad", 8], + ["🇨🇱", "chile", 8], + ["🇨🇳", "cn", 8], + ["🇨🇽", "christmas_island", 8], + ["🇨🇨", "cocos_islands", 8], + ["🇨🇴", "colombia", 8], + ["🇰🇲", "comoros", 8], + ["🇨🇬", "congo_brazzaville", 8], + ["🇨🇩", "congo_kinshasa", 8], + ["🇨🇰", "cook_islands", 8], + ["🇨🇷", "costa_rica", 8], + ["🇭🇷", "croatia", 8], + ["🇨🇺", "cuba", 8], + ["🇨🇼", "curacao", 8], + ["🇨🇾", "cyprus", 8], + ["🇨🇿", "czech_republic", 8], + ["🇩🇰", "denmark", 8], + ["🇩🇯", "djibouti", 8], + ["🇩🇲", "dominica", 8], + ["🇩🇴", "dominican_republic", 8], + ["🇪🇨", "ecuador", 8], + ["🇪🇬", "egypt", 8], + ["🇸🇻", "el_salvador", 8], + ["🇬🇶", "equatorial_guinea", 8], + ["🇪🇷", "eritrea", 8], + ["🇪🇪", "estonia", 8], + ["🇪🇹", "ethiopia", 8], + ["🇪🇺", "eu", 8], + ["🇫🇰", "falkland_islands", 8], + ["🇫🇴", "faroe_islands", 8], + ["🇫🇯", "fiji", 8], + ["🇫🇮", "finland", 8], + ["🇫🇷", "fr", 8], + ["🇬🇫", "french_guiana", 8], + ["🇵🇫", "french_polynesia", 8], + ["🇹🇫", "french_southern_territories", 8], + ["🇬🇦", "gabon", 8], + ["🇬🇲", "gambia", 8], + ["🇬🇪", "georgia", 8], + ["🇩🇪", "de", 8], + ["🇬🇭", "ghana", 8], + ["🇬🇮", "gibraltar", 8], + ["🇬🇷", "greece", 8], + ["🇬🇱", "greenland", 8], + ["🇬🇩", "grenada", 8], + ["🇬🇵", "guadeloupe", 8], + ["🇬🇺", "guam", 8], + ["🇬🇹", "guatemala", 8], + ["🇬🇬", "guernsey", 8], + ["🇬🇳", "guinea", 8], + ["🇬🇼", "guinea_bissau", 8], + ["🇬🇾", "guyana", 8], + ["🇭🇹", "haiti", 8], + ["🇭🇳", "honduras", 8], + ["🇭🇰", "hong_kong", 8], + ["🇭🇺", "hungary", 8], + ["🇮🇸", "iceland", 8], + ["🇮🇳", "india", 8], + ["🇮🇩", "indonesia", 8], + ["🇮🇷", "iran", 8], + ["🇮🇶", "iraq", 8], + ["🇮🇪", "ireland", 8], + ["🇮🇲", "isle_of_man", 8], + ["🇮🇱", "israel", 8], + ["🇮🇹", "it", 8], + ["🇨🇮", "cote_divoire", 8], + ["🇯🇲", "jamaica", 8], + ["🇯🇵", "jp", 8], + ["🇯🇪", "jersey", 8], + ["🇯🇴", "jordan", 8], + ["🇰🇿", "kazakhstan", 8], + ["🇰🇪", "kenya", 8], + ["🇰🇮", "kiribati", 8], + ["🇽🇰", "kosovo", 8], + ["🇰🇼", "kuwait", 8], + ["🇰🇬", "kyrgyzstan", 8], + ["🇱🇦", "laos", 8], + ["🇱🇻", "latvia", 8], + ["🇱🇧", "lebanon", 8], + ["🇱🇸", "lesotho", 8], + ["🇱🇷", "liberia", 8], + ["🇱🇾", "libya", 8], + ["🇱🇮", "liechtenstein", 8], + ["🇱🇹", "lithuania", 8], + ["🇱🇺", "luxembourg", 8], + ["🇲🇴", "macau", 8], + ["🇲🇰", "macedonia", 8], + ["🇲🇬", "madagascar", 8], + ["🇲🇼", "malawi", 8], + ["🇲🇾", "malaysia", 8], + ["🇲🇻", "maldives", 8], + ["🇲🇱", "mali", 8], + ["🇲🇹", "malta", 8], + ["🇲🇭", "marshall_islands", 8], + ["🇲🇶", "martinique", 8], + ["🇲🇷", "mauritania", 8], + ["🇲🇺", "mauritius", 8], + ["🇾🇹", "mayotte", 8], + ["🇲🇽", "mexico", 8], + ["🇫🇲", "micronesia", 8], + ["🇲🇩", "moldova", 8], + ["🇲🇨", "monaco", 8], + ["🇲🇳", "mongolia", 8], + ["🇲🇪", "montenegro", 8], + ["🇲🇸", "montserrat", 8], + ["🇲🇦", "morocco", 8], + ["🇲🇿", "mozambique", 8], + ["🇲🇲", "myanmar", 8], + ["🇳🇦", "namibia", 8], + ["🇳🇷", "nauru", 8], + ["🇳🇵", "nepal", 8], + ["🇳🇱", "netherlands", 8], + ["🇳🇨", "new_caledonia", 8], + ["🇳🇿", "new_zealand", 8], + ["🇳🇮", "nicaragua", 8], + ["🇳🇪", "niger", 8], + ["🇳🇬", "nigeria", 8], + ["🇳🇺", "niue", 8], + ["🇳🇫", "norfolk_island", 8], + ["🇲🇵", "northern_mariana_islands", 8], + ["🇰🇵", "north_korea", 8], + ["🇳🇴", "norway", 8], + ["🇴🇲", "oman", 8], + ["🇵🇰", "pakistan", 8], + ["🇵🇼", "palau", 8], + ["🇵🇸", "palestinian_territories", 8], + ["🇵🇦", "panama", 8], + ["🇵🇬", "papua_new_guinea", 8], + ["🇵🇾", "paraguay", 8], + ["🇵🇪", "peru", 8], + ["🇵🇭", "philippines", 8], + ["🇵🇳", "pitcairn_islands", 8], + ["🇵🇱", "poland", 8], + ["🇵🇹", "portugal", 8], + ["🇵🇷", "puerto_rico", 8], + ["🇶🇦", "qatar", 8], + ["🇷🇪", "reunion", 8], + ["🇷🇴", "romania", 8], + ["🇷🇺", "ru", 8], + ["🇷🇼", "rwanda", 8], + ["🇧🇱", "st_barthelemy", 8], + ["🇸🇭", "st_helena", 8], + ["🇰🇳", "st_kitts_nevis", 8], + ["🇱🇨", "st_lucia", 8], + ["🇵🇲", "st_pierre_miquelon", 8], + ["🇻🇨", "st_vincent_grenadines", 8], + ["🇼🇸", "samoa", 8], + ["🇸🇲", "san_marino", 8], + ["🇸🇹", "sao_tome_principe", 8], + ["🇸🇦", "saudi_arabia", 8], + ["🇸🇳", "senegal", 8], + ["🇷🇸", "serbia", 8], + ["🇸🇨", "seychelles", 8], + ["🇸🇱", "sierra_leone", 8], + ["🇸🇬", "singapore", 8], + ["🇸🇽", "sint_maarten", 8], + ["🇸🇰", "slovakia", 8], + ["🇸🇮", "slovenia", 8], + ["🇸🇧", "solomon_islands", 8], + ["🇸🇴", "somalia", 8], + ["🇿🇦", "south_africa", 8], + ["🇬🇸", "south_georgia_south_sandwich_islands", 8], + ["🇰🇷", "kr", 8], + ["🇸🇸", "south_sudan", 8], + ["🇪🇸", "es", 8], + ["🇱🇰", "sri_lanka", 8], + ["🇸🇩", "sudan", 8], + ["🇸🇷", "suriname", 8], + ["🇸🇿", "swaziland", 8], + ["🇸🇪", "sweden", 8], + ["🇨🇭", "switzerland", 8], + ["🇸🇾", "syria", 8], + ["🇹🇼", "taiwan", 8], + ["🇹🇯", "tajikistan", 8], + ["🇹🇿", "tanzania", 8], + ["🇹🇭", "thailand", 8], + ["🇹🇱", "timor_leste", 8], + ["🇹🇬", "togo", 8], + ["🇹🇰", "tokelau", 8], + ["🇹🇴", "tonga", 8], + ["🇹🇹", "trinidad_tobago", 8], + ["🇹🇦", "tristan_da_cunha", 8], + ["🇹🇳", "tunisia", 8], + ["🇹🇷", "tr", 8], + ["🇹🇲", "turkmenistan", 8], + ["🇹🇨", "turks_caicos_islands", 8], + ["🇹🇻", "tuvalu", 8], + ["🇺🇬", "uganda", 8], + ["🇺🇦", "ukraine", 8], + ["🇦🇪", "united_arab_emirates", 8], + ["🇬🇧", "uk", 8], + ["🏴", "england", 8], + ["🏴", "scotland", 8], + ["🏴", "wales", 8], + ["🇺🇸", "us", 8], + ["🇻🇮", "us_virgin_islands", 8], + ["🇺🇾", "uruguay", 8], + ["🇺🇿", "uzbekistan", 8], + ["🇻🇺", "vanuatu", 8], + ["🇻🇦", "vatican_city", 8], + ["🇻🇪", "venezuela", 8], + ["🇻🇳", "vietnam", 8], + ["🇼🇫", "wallis_futuna", 8], + ["🇪🇭", "western_sahara", 8], + ["🇾🇪", "yemen", 8], + ["🇿🇲", "zambia", 8], + ["🇿🇼", "zimbabwe", 8], + ["🇺🇳", "united_nations", 8], + ["🏴☠️", "pirate_flag", 8] ] - diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 220c6210c0..30771ec1b3 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -1,8 +1,9 @@ import { markRaw } from 'vue'; +import type { Locale } from '../../../locales'; import { locale } from '@/config'; import { I18n } from '@/scripts/i18n'; -export const i18n = markRaw(new I18n(locale)); +export const i18n = markRaw(new I18n<Locale>(locale)); export function updateI18n(newLocale) { i18n.ts = newLocale; diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts deleted file mode 100644 index 49e7bb4008..0000000000 --- a/packages/frontend/src/init.ts +++ /dev/null @@ -1,527 +0,0 @@ -/** - * Client entry point - */ -// https://vitejs.dev/config/build-options.html#build-modulepreload -import 'vite/modulepreload-polyfill'; - -import '@/style.scss'; - -import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; -import { compareVersions } from 'compare-versions'; -import JSON5 from 'json5'; - -import widgets from '@/widgets'; -import directives from '@/directives'; -import components from '@/components'; -import { version, ui, lang, updateLocale } from '@/config'; -import { applyTheme } from '@/scripts/theme'; -import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; -import { i18n, updateI18n } from '@/i18n'; -import { confirm, alert, post, popup, toast } from '@/os'; -import { stream } from '@/stream'; -import * as sound from '@/scripts/sound'; -import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; -import { defaultStore, ColdDeviceStorage } from '@/store'; -import { fetchInstance, instance } from '@/instance'; -import { makeHotkey } from '@/scripts/hotkey'; -import { deviceKind } from '@/scripts/device-kind'; -import { initializeSw } from '@/scripts/initialize-sw'; -import { reloadChannel } from '@/scripts/unison-reload'; -import { reactionPicker } from '@/scripts/reaction-picker'; -import { getUrlWithoutLoginId } from '@/scripts/login-id'; -import { getAccountFromId } from '@/scripts/get-account-from-id'; -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}`); - -if (_DEV_) { - console.warn('Development mode!!!'); - - console.info(`vue ${vueVersion}`); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$i = $i; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$store = defaultStore; - - window.addEventListener('error', event => { - console.error(event); - /* - alert({ - type: 'error', - title: 'DEV: Unhandled error', - text: event.message - }); - */ - }); - - window.addEventListener('unhandledrejection', event => { - console.error(event); - /* - alert({ - type: 'error', - title: 'DEV: Unhandled promise rejection', - text: event.reason - }); - */ - }); -} - -//#region Detect language & fetch translations -const localeVersion = miLocalStorage.getItem('localeVersion'); -const localeOutdated = (localeVersion == null || localeVersion !== version); -if (localeOutdated) { - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); - if (res.status === 200) { - const newLocale = await res.text(); - const parsedNewLocale = JSON.parse(newLocale); - miLocalStorage.setItem('locale', newLocale); - miLocalStorage.setItem('localeVersion', version); - updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); - } -} -//#endregion - -// タッチデバイスでCSSの:hoverを機能させる -document.addEventListener('touchend', () => {}, { passive: true }); - -// 一斉リロード -reloadChannel.addEventListener('message', path => { - if (path !== null) location.href = path; - else location.reload(); -}); - -// If mobile, insert the viewport meta tag -if (['smartphone', 'tablet'].includes(deviceKind)) { - const viewport = document.getElementsByName('viewport').item(0); - viewport.setAttribute('content', - `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); -} - -//#region Set lang attr -const html = document.documentElement; -html.setAttribute('lang', lang); -//#endregion - -//#region loginId -const params = new URLSearchParams(location.search); -const loginId = params.get('loginId'); - -if (loginId) { - const target = getUrlWithoutLoginId(location.href); - - if (!$i || $i.id !== loginId) { - const account = await getAccountFromId(loginId); - if (account) { - await login(account.token, target); - } - } - - history.replaceState({ misskey: 'loginId' }, '', target); -} - -//#endregion - -//#region Fetch user -if ($i && $i.token) { - if (_DEV_) { - console.log('account cache found. refreshing...'); - } - - refreshAccount(); -} else { - if (_DEV_) { - console.log('no account cache found.'); - } - - // 連携ログインの場合用にCookieを参照する - const i = (document.cookie.match(/igi=(\w+)/) ?? [null, null])[1]; - - if (i != null && i !== 'null') { - if (_DEV_) { - console.log('signing...'); - } - - try { - document.body.innerHTML = '<div>Please wait...</div>'; - await login(i); - } catch (err) { - // Render the error screen - // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) - document.body.innerHTML = '<div id="err">Oops!</div>'; - } - } else { - if (_DEV_) { - console.log('not signed in'); - } - } -} -//#endregion - -const fetchInstanceMetaPromise = fetchInstance(); - -fetchInstanceMetaPromise.then(() => { - miLocalStorage.setItem('v', instance.version); - - // Init service worker - initializeSw(); -}); - -try { - await fetchCustomEmojis(); -} catch (err) { /* empty */ } - -const app = createApp( - new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : - !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : - ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : - ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : - defineAsyncComponent(() => import('@/ui/universal.vue')), -); - -if (_DEV_) { - app.config.performance = true; -} - -widgets(app); -directives(app); -components(app); - -const splash = document.getElementById('splash'); -// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) -if (splash) splash.addEventListener('transitionend', () => { - splash.remove(); -}); - -await deckStore.ready; - -// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 -// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する -const rootEl = ((): HTMLElement => { - const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; - - const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); - - if (currentRoot) { - console.warn('multiple import detected'); - return currentRoot; - } - - const root = document.createElement('div'); - root.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(root); - return root; -})(); - -app.mount(rootEl); - -// boot.jsのやつを解除 -window.onerror = null; -window.onunhandledrejection = null; - -reactionPicker.init(); - -if (splash) { - splash.style.opacity = '0'; - splash.style.pointerEvents = 'none'; -} - -// クライアントが更新されたか? -const lastVersion = miLocalStorage.getItem('lastVersion'); -if (lastVersion !== version) { - miLocalStorage.setItem('lastVersion', version); - - // テーマリビルドするため - miLocalStorage.removeItem('theme'); - - try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため - if (lastVersion != null && compareVersions(version, lastVersion) === 1) { - // ログインしてる場合だけ - if ($i) { - popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); - } - } - } catch (err) { /* empty */ } -} - -await defaultStore.ready; - -// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) -watch(defaultStore.reactiveState.darkMode, (darkMode) => { - applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); -}, { immediate: miLocalStorage.getItem('theme') == null }); - -const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); -const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); - -watch(darkTheme, (theme) => { - if (defaultStore.state.darkMode) { - applyTheme(theme); - } -}); - -watch(lightTheme, (theme) => { - if (!defaultStore.state.darkMode) { - applyTheme(theme); - } -}); - -//#region Sync dark mode -if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', isDeviceDarkmode()); -} - -window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', mql.matches); - } -}); -//#endregion - -fetchInstanceMetaPromise.then(() => { - if (defaultStore.state.themeInitial) { - if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme)); - if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme)); - defaultStore.set('themeInitial', false); - } -}); - -watch(defaultStore.reactiveState.useBlurEffectForModal, v => { - document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); -}, { immediate: true }); - -watch(defaultStore.reactiveState.useBlurEffect, v => { - if (v) { - document.documentElement.style.removeProperty('--blur'); - } else { - document.documentElement.style.setProperty('--blur', 'none'); - } -}, { immediate: true }); - -let reloadDialogShowing = false; -stream.on('_disconnected_', async () => { - if (defaultStore.state.serverDisconnectedBehavior === 'reload') { - location.reload(); - } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { - if (reloadDialogShowing) return; - reloadDialogShowing = true; - const { canceled } = await confirm({ - type: 'warning', - title: i18n.ts.disconnectedFromServer, - text: i18n.ts.reloadConfirm, - }); - reloadDialogShowing = false; - if (!canceled) { - location.reload(); - } - } -}); - -for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { - import('./plugin').then(async ({ install }) => { - // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 - await new Promise(r => setTimeout(r, 0)); - install(plugin); - }); -} - -const hotkeys = { - 'd': (): void => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 's': (): void => { - mainRouter.push('/search'); - }, -}; - -if ($i) { - // only add post shortcuts if logged in - hotkeys['p|n'] = post; - - if (defaultStore.state.accountSetupWizard !== -1) { - // このウィザードが実装される前に登録したユーザーには表示させないため - // TODO: そのうち消す - if (Date.now() - new Date($i.createdAt).getTime() < 1000 * 60 * 60 * 24) { - popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed'); - } else { - defaultStore.set('accountSetupWizard', -1); - } - } - - if ($i.isDeleted) { - alert({ - type: 'warning', - text: i18n.ts.accountDeletionInProgress, - }); - } - - const now = new Date(); - const m = now.getMonth() + 1; - const d = now.getDate(); - - if ($i.birthday) { - const bm = parseInt($i.birthday.split('-')[1]); - const bd = parseInt($i.birthday.split('-')[2]); - if (m === bm && d === bd) { - claimAchievement('loggedInOnBirthday'); - } - } - - if (m === 1 && d === 1) { - claimAchievement('loggedInOnNewYearsDay'); - } - - if ($i.loggedInDays >= 3) claimAchievement('login3'); - if ($i.loggedInDays >= 7) claimAchievement('login7'); - if ($i.loggedInDays >= 15) claimAchievement('login15'); - if ($i.loggedInDays >= 30) claimAchievement('login30'); - if ($i.loggedInDays >= 60) claimAchievement('login60'); - if ($i.loggedInDays >= 100) claimAchievement('login100'); - if ($i.loggedInDays >= 200) claimAchievement('login200'); - if ($i.loggedInDays >= 300) claimAchievement('login300'); - if ($i.loggedInDays >= 400) claimAchievement('login400'); - if ($i.loggedInDays >= 500) claimAchievement('login500'); - if ($i.loggedInDays >= 600) claimAchievement('login600'); - if ($i.loggedInDays >= 700) claimAchievement('login700'); - if ($i.loggedInDays >= 800) claimAchievement('login800'); - if ($i.loggedInDays >= 900) claimAchievement('login900'); - if ($i.loggedInDays >= 1000) claimAchievement('login1000'); - - if ($i.notesCount > 0) claimAchievement('notes1'); - if ($i.notesCount >= 10) claimAchievement('notes10'); - if ($i.notesCount >= 100) claimAchievement('notes100'); - if ($i.notesCount >= 500) claimAchievement('notes500'); - if ($i.notesCount >= 1000) claimAchievement('notes1000'); - if ($i.notesCount >= 5000) claimAchievement('notes5000'); - if ($i.notesCount >= 10000) claimAchievement('notes10000'); - if ($i.notesCount >= 20000) claimAchievement('notes20000'); - if ($i.notesCount >= 30000) claimAchievement('notes30000'); - if ($i.notesCount >= 40000) claimAchievement('notes40000'); - if ($i.notesCount >= 50000) claimAchievement('notes50000'); - if ($i.notesCount >= 60000) claimAchievement('notes60000'); - if ($i.notesCount >= 70000) claimAchievement('notes70000'); - if ($i.notesCount >= 80000) claimAchievement('notes80000'); - if ($i.notesCount >= 90000) claimAchievement('notes90000'); - if ($i.notesCount >= 100000) claimAchievement('notes100000'); - - if ($i.followersCount > 0) claimAchievement('followers1'); - if ($i.followersCount >= 10) claimAchievement('followers10'); - if ($i.followersCount >= 50) claimAchievement('followers50'); - if ($i.followersCount >= 100) claimAchievement('followers100'); - if ($i.followersCount >= 300) claimAchievement('followers300'); - if ($i.followersCount >= 500) claimAchievement('followers500'); - if ($i.followersCount >= 1000) claimAchievement('followers1000'); - - if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) { - claimAchievement('passedSinceAccountCreated1'); - } - if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) { - claimAchievement('passedSinceAccountCreated2'); - } - if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) { - claimAchievement('passedSinceAccountCreated3'); - } - - if (claimedAchievements.length >= 30) { - claimAchievement('collectAchievements30'); - } - - window.setInterval(() => { - if (Math.floor(Math.random() * 20000) === 0) { - claimAchievement('justPlainLucky'); - } - }, 1000 * 10); - - window.setTimeout(() => { - claimAchievement('client30min'); - }, 1000 * 60 * 30); - - window.setTimeout(() => { - claimAchievement('client60min'); - }, 1000 * 60 * 60); - - const lastUsed = miLocalStorage.getItem('lastUsed'); - if (lastUsed) { - const lastUsedDate = parseInt(lastUsed, 10); - // 二時間以上前なら - if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { - toast(i18n.t('welcomeBackWithName', { - name: $i.name || $i.username, - })); - } - } - miLocalStorage.setItem('lastUsed', Date.now().toString()); - - const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); - const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); - if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { - if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { - popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed'); - } - } - - if ('Notification' in window) { - // 許可を得ていなかったらリクエスト - if (Notification.permission === 'default') { - Notification.requestPermission(); - } - } - - const main = markRaw(stream.useChannel('main', null, 'System')); - - // 自分の情報が更新されたとき - main.on('meUpdated', i => { - updateAccount(i); - }); - - main.on('readAllNotifications', () => { - updateAccount({ hasUnreadNotification: false }); - }); - - main.on('unreadNotification', () => { - updateAccount({ hasUnreadNotification: true }); - }); - - main.on('unreadMention', () => { - updateAccount({ hasUnreadMentions: true }); - }); - - main.on('readAllUnreadMentions', () => { - updateAccount({ hasUnreadMentions: false }); - }); - - main.on('unreadSpecifiedNote', () => { - updateAccount({ hasUnreadSpecifiedNotes: true }); - }); - - main.on('readAllUnreadSpecifiedNotes', () => { - updateAccount({ hasUnreadSpecifiedNotes: false }); - }); - - main.on('readAllAntennas', () => { - updateAccount({ hasUnreadAntenna: false }); - }); - - main.on('unreadAntenna', () => { - updateAccount({ hasUnreadAntenna: true }); - sound.play('antenna'); - }); - - main.on('readAllAnnouncements', () => { - updateAccount({ hasUnreadAnnouncement: false }); - }); - - // トークンが再生成されたとき - // このままではMisskeyが利用できないので強制的にサインアウトさせる - main.on('myTokenRegenerated', () => { - signout(); - }); -} - -// shortcut -document.addEventListener('keydown', makeHotkey(hotkeys)); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 441a35747a..ca4f21f79b 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -13,7 +13,7 @@ type Keys = 'hashtags' | 'wallpaper' | 'theme' | - 'colorSchema' | + 'colorScheme' | 'useSystemFont' | 'fontSize' | 'ui' | diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index c4f9d47d7d..c44d348046 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -172,12 +172,6 @@ export function pageWindow(path: string) { }, {}, 'closed'); } -export function modalPageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), { - initialPath: path, - }, {}, 'closed'); -} - export function toast(message: string) { popup(MkToast, { message, diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue index f53fec7d94..f27d2df336 100644 --- a/packages/frontend/src/pages/_error_.vue +++ b/packages/frontend/src/pages/_error_.vue @@ -1,18 +1,20 @@ <template> <MkLoading v-if="!loaded"/> <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> - <div v-show="loaded" class="mjndxjch"> - <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> - <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p> - <p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p> - <p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p> - <template v-else> - <p>{{ i18n.ts.newVersionOfClientAvailable }}</p> - <p>{{ i18n.ts.youShouldUpgradeClient }}</p> - <MkButton class="button primary" @click="reload">{{ i18n.ts.reload }}</MkButton> - </template> - <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p> - <p v-if="error" class="error">ERROR: {{ error }}</p> + <div v-show="loaded" :class="$style.root"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost" :class="$style.img"/> + <div class="_gaps"> + <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p> + <p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p> + <p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p> + <template v-else> + <p>{{ i18n.ts.newVersionOfClientAvailable }}</p> + <p>{{ i18n.ts.youShouldUpgradeClient }}</p> + <MkButton style="margin: 8px auto;" @click="reload">{{ i18n.ts.reload }}</MkButton> + </template> + <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p> + <p v-if="error" style="opacity: 0.7;">ERROR: {{ error }}</p> + </div> </div> </Transition> </template> @@ -64,28 +66,16 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.mjndxjch { +<style lang="scss" module> +.root { padding: 32px; text-align: center; +} - > p { - margin: 0 0 12px 0; - } - - > .button { - margin: 8px auto; - } - - > img { - vertical-align: bottom; - height: 128px; - margin-bottom: 24px; - border-radius: 16px; - } - - > .error { - opacity: 0.7; - } +.img { + vertical-align: bottom; + height: 128px; + margin-bottom: 24px; + border-radius: 16px; } </style> diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 9e0594db3c..0017145fa1 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <div style="overflow: clip;"> - <MkSpacer :content-max="600" :margin-min="20"> + <MkSpacer :contentMax="600" :marginMin="20"> <div class="_gaps_m znqjceqz"> <div v-panel class="about"> <div ref="containerEl" class="container" :class="{ playing: easterEggEngine != null }"> @@ -10,8 +10,8 @@ <div class="misskey">Misskey</div> <div class="version">v{{ version }}</div> <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"> - <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :no-style="true"/> - <MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :no-style="true"/> + <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true"/> + <MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :noStyle="true"/> </span> </div> <button v-if="thereIsTreasure" class="_button treasure" @click="getTreasure"><img src="/fluent-emoji/1f3c6.png" class="treasureImg"></button> @@ -86,8 +86,13 @@ </FormSection> <FormSection> <template #label>Special thanks</template> - <div style="text-align: center;"> - <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a> + <div class="_gaps" style="text-align: center;"> + <div> + <a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a> + </div> + <div> + <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a> + </div> </div> </FormSection> </div> @@ -144,6 +149,12 @@ const patronsWithIcon = [{ }, { name: 'かみらえっと', icon: 'https://misskey-hub.net/patrons/be1326bda7d940a482f3758ffd9ffaf6.jpg', +}, { + name: 'へてて', + icon: 'https://misskey-hub.net/patrons/0431eacd7c6843d09de8ea9984307e86.jpg', +}, { + name: 'spinlock', + icon: 'https://misskey-hub.net/patrons/6a1cebc819d540a78bf20e9e3115baa8.jpg', }]; const patrons = [ diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 2d82fcf277..3744bed10f 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -1,5 +1,5 @@ <template> -<div class="driuhtrh _gaps"> +<div class="_gaps"> <MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton> <div class="query"> @@ -14,17 +14,17 @@ --> </div> - <MkFoldableSection v-if="searchEmojis" class="emojis"> + <MkFoldableSection v-if="searchEmojis"> <template #header>{{ i18n.ts.searchResult }}</template> - <div class="zuvgdzyt"> - <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/> + <div :class="$style.emojis"> + <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/> </div> </MkFoldableSection> - <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category" class="emojis"> + <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> <template #header>{{ category || i18n.ts.other }}</template> - <div class="zuvgdzyt"> - <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/> + <div :class="$style.emojis"> + <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> </div> </MkFoldableSection> </div> @@ -57,7 +57,7 @@ function search() { if (queryarry) { searchEmojis = customEmojis.value.filter(emoji => - queryarry.includes(`:${emoji.name}:`) + queryarry.includes(`:${emoji.name}:`), ); } else { searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q)); @@ -84,36 +84,10 @@ watch($$(selectedTags), () => { }, { deep: true }); </script> -<style lang="scss" scoped> -.driuhtrh { - background: var(--bg); - - > .query { - background: var(--bg); - - > .tags { - > .tag { - display: inline-block; - margin: 8px 8px 0 0; - padding: 4px 8px; - font-size: 0.9em; - background: var(--accentedBg); - border-radius: 5px; - - &.active { - background: var(--accent); - color: var(--fgOnAccent); - } - } - } - } - - > .emojis { - .zuvgdzyt { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - grid-gap: 12px; - } - } +<style lang="scss" module> +.emojis { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; } </style> diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 8fe613a9a8..a8c6c05d8b 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -1,6 +1,6 @@ <template> -<div class="taeiyria"> - <div class="query"> +<div> + <div> <MkInput v-model="host" :debounce="true" class=""> <template #prefix><i class="ti ti-search"></i></template> <template #label>{{ i18n.ts.host }}</template> @@ -35,8 +35,8 @@ </div> <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> - <div class="dqokceoi"> - <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`"> + <div :class="$style.items"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`"> <MkInstanceCardMini :instance="instance"/> </MkA> </div> @@ -82,21 +82,14 @@ function getStatus(instance) { } </script> -<style lang="scss" scoped> -.taeiyria { - > .query { - background: var(--bg); - margin-bottom: 16px; - } -} - -.dqokceoi { +<style lang="scss" module> +.items { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px; +} - > .instance:hover { - text-decoration: none; - } +.item:hover { + text-decoration: none; } </style> diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 8e29990426..693d369b89 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> + <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> <div class="_gaps_m"> <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> <div style="overflow: clip;"> @@ -80,13 +80,13 @@ </FormSection> </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20"> + <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> <XEmojis/> </MkSpacer> - <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20"> + <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> <XFederation/> </MkSpacer> - <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20"> + <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> <MkInstanceStats/> </MkSpacer> </MkStickyContainer> diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue index 1eef7a53fe..dc47d8dde0 100644 --- a/packages/frontend/src/pages/achievements.vue +++ b/packages/frontend/src/pages/achievements.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader/></template> - <MkSpacer :content-max="1200"> + <MkSpacer :contentMax="1200"> <MkAchievements :user="$i"/> </MkSpacer> </MkStickyContainer> diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 1d309a7377..24c863ba62 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32"> + <MkSpacer v-if="file" :contentMax="600" :marginMin="16" :marginMax="32"> <div v-if="tab === 'overview'" class="cxqhhsmd _gaps_m"> <a class="thumbnail" :href="file.url" target="_blank"> <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> @@ -32,7 +32,7 @@ <MkUserCardMini :user="file.user"/> </MkA> <div> - <MkSwitch v-model="isSensitive" @update:model-value="toggleIsSensitive">NSFW</MkSwitch> + <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch> </div> <div> diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 343d2c4c5c..9530b27bad 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -1,5 +1,5 @@ <template> -<div :class="$style.root" class="_gaps"> +<div class="_gaps"> <div :class="$style.header"> <MkSelect v-model="type" :class="$style.typeSelect"> <option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option> @@ -24,12 +24,12 @@ </button> </div> - <div v-if="type === 'and' || type === 'or'" :class="$style.values" class="_gaps"> - <Sortable v-model="v.values" tag="div" class="_gaps" item-key="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swap-threshold="0.5"> + <div v-if="type === 'and' || type === 'or'" class="_gaps"> + <Sortable v-model="v.values" tag="div" class="_gaps" itemKey="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swapThreshold="0.5"> <template #item="{element}"> <div :class="$style.item"> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <RolesEditorFormula :model-value="element" draggable @update:model-value="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/> + <RolesEditorFormula :modelValue="element" draggable @update:modelValue="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/> </div> </template> </Sortable> @@ -118,10 +118,6 @@ function removeSelf() { </script> <style lang="scss" module> -.root { - -} - .header { display: flex; } @@ -148,8 +144,4 @@ function removeSelf() { border-color: var(--accent); } } - -.values { - -} </style> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 9e8af43024..3bc5ee9723 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -1,8 +1,8 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900"> - <div class="lcixvhis"> + <MkSpacer :contentMax="900"> + <div> <div class="reports"> <div class=""> <div class="inputs" style="display: flex;"> @@ -87,9 +87,3 @@ definePageMetadata({ icon: 'ti ti-exclamation-circle', }); </script> - -<style lang="scss" scoped> -.lcixvhis { - margin: var(--margin); -} -</style> diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 803e8cb7b0..2c9e18b0bf 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -1,9 +1,9 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900"> - <div class="uqshojas"> - <div v-for="ad in ads" class="_panel _gaps_m ad"> + <MkSpacer :contentMax="900"> + <div> + <div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad"> <MkAd v-if="ad.url" :specify="ad"/> <MkInput v-model="ad.url" type="url"> <template #label>URL</template> @@ -196,14 +196,12 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.uqshojas { - > .ad { - padding: 32px; +<style lang="scss" module> +.ad { + padding: 32px; - &:not(:last-child) { - margin-bottom: var(--margin); - } + &:not(:last-child) { + margin-bottom: var(--margin); } } </style> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index b76e4b9114..3cb32c1d9d 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -1,8 +1,8 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900"> - <div class="ztgjmzrw _gaps_m"> + <MkSpacer :contentMax="900"> + <div class="_gaps_m"> <section v-for="announcement in announcements" class=""> <div class="_panel _gaps_m" style="padding: 24px;"> <MkInput v-model="announcement.title"> @@ -113,9 +113,3 @@ definePageMetadata({ icon: 'ti ti-speakerphone', }); </script> - -<style lang="scss" scoped> -.ztgjmzrw { - margin: var(--margin); -} -</style> diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue index 5a0d3d5e51..131e586afd 100644 --- a/packages/frontend/src/pages/admin/database.vue +++ b/packages/frontend/src/pages/admin/database.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> <template #key>{{ table[0] }}</template> diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index d51bf6230a..4f5bb379ad 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> <MkSwitch v-model="enableEmail"> @@ -18,7 +18,7 @@ <template #label>{{ i18n.ts.smtpConfig }}</template> <div class="_gaps_m"> - <FormSplit :min-width="280"> + <FormSplit :minWidth="280"> <MkInput v-model="smtpHost"> <template #label>{{ i18n.ts.smtpHost }}</template> </MkInput> @@ -26,7 +26,7 @@ <template #label>{{ i18n.ts.smtpPort }}</template> </MkInput> </FormSplit> - <FormSplit :min-width="280"> + <FormSplit :minWidth="280"> <MkInput v-model="smtpUser"> <template #label>{{ i18n.ts.smtpUser }}</template> </MkInput> @@ -47,7 +47,7 @@ </MkSpacer> <template #footer> <div :class="$style.footer"> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <div class="_buttons"> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> <MkButton rounded @click="testEmail"><i class="ti ti-send"></i> {{ i18n.ts.testEmail }}</MkButton> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 6af1610431..a8d6dcf3de 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -2,9 +2,9 @@ <div> <MkStickyContainer> <template #header><XHeader :actions="headerActions"/></template> - <MkSpacer :content-max="900"> - <div class="taeiyrib"> - <div class="query"> + <MkSpacer :contentMax="900"> + <div class="_gaps"> + <div> <MkInput v-model="host" :debounce="true" class=""> <template #prefix><i class="ti ti-search"></i></template> <template #label>{{ i18n.ts.host }}</template> @@ -39,8 +39,8 @@ </div> <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> - <div class="dqokceoj"> - <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`"> + <div :class="$style.instances"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.instance" :to="`/instance-info/${instance.host}`"> <MkInstanceCardMini :instance="instance"/> </MkA> </div> @@ -100,21 +100,14 @@ definePageMetadata(computed(() => ({ }))); </script> -<style lang="scss" scoped> -.taeiyrib { - > .query { - background: var(--bg); - margin-bottom: 16px; - } -} - -.dqokceoj { +<style lang="scss" module> +.instances { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px; +} - > .instance:hover { - text-decoration: none; - } +.instance:hover { + text-decoration: none; } </style> diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index c189437246..b204a1a64a 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -2,30 +2,28 @@ <div> <MkStickyContainer> <template #header><XHeader :actions="headerActions"/></template> - <MkSpacer :content-max="900"> - <div class="xrmjdkdw"> - <div> - <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> - <MkSelect v-model="origin" style="margin: 0; flex: 1;"> - <template #label>{{ i18n.ts.instance }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkSelect> - <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - </div> - <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;"> - <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> - <template #label>User ID</template> - </MkInput> - <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> - <template #label>MIME type</template> - </MkInput> - </div> - <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/> + <MkSpacer :contentMax="900"> + <div class="_gaps"> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> </div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>User ID</template> + </MkInput> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkFileListForAdmin :pagination="pagination" :viewMode="viewMode"/> </div> </MkSpacer> </MkStickyContainer> @@ -109,9 +107,3 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-cloud', }))); </script> - -<style lang="scss" scoped> -.xrmjdkdw { - margin: var(--margin); -} -</style> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 963393d7e5..5cbbcaa44c 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -1,7 +1,7 @@ <template> <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> <div v-if="!narrow || currentPage?.route.name == null" class="nav"> - <MkSpacer :content-max="700" :margin-min="16"> + <MkSpacer :contentMax="700" :marginMin="16"> <div class="lxpfedzu"> <div class="banner"> <img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue index 7a4937093e..e5f3816c82 100644 --- a/packages/frontend/src/pages/admin/instance-block.vue +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <MkTextarea v-model="blockedHosts"> <span>{{ i18n.ts.blockedInstances }}</span> diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index bf788e3609..e36c9ac91d 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> <MkSwitch v-model="enableRegistration"> @@ -34,7 +34,7 @@ </MkSpacer> <template #footer> <div :class="$style.footer"> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </MkSpacer> </div> diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index 704b27c174..e569aad1b8 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> <MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch> @@ -33,7 +33,7 @@ <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> </MkInput> - <FormSplit :min-width="280"> + <FormSplit :minWidth="280"> <MkInput v-model="objectStorageAccessKey"> <template #prefix><i class="ti ti-key"></i></template> <template #label>Access key</template> @@ -69,7 +69,7 @@ </MkSpacer> <template #footer> <div :class="$style.footer"> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </MkSpacer> </div> diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue index 62dff6ce7f..15d720a070 100644 --- a/packages/frontend/src/pages/admin/other-settings.vue +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -1,9 +1,17 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> - none + <div class="_gaps_s"> + <MkSwitch v-model="enableChartsForRemoteUser"> + <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> + </MkSwitch> + + <MkSwitch v-model="enableChartsForFederatedInstances"> + <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template> + </MkSwitch> + </div> </FormSuspense> </MkSpacer> </MkStickyContainer> @@ -17,13 +25,22 @@ import * as os from '@/os'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkSwitch from '@/components/MkSwitch.vue'; + +let enableChartsForRemoteUser: boolean = $ref(false); +let enableChartsForFederatedInstances: boolean = $ref(false); async function init() { - await os.api('admin/meta'); + const meta = await os.api('admin/meta'); + enableChartsForRemoteUser = meta.enableChartsForRemoteUser; + enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; } function save() { - os.apiWithDialog('admin/update-meta').then(() => { + os.apiWithDialog('admin/update-meta', { + enableChartsForRemoteUser, + enableChartsForFederatedInstances, + }).then(() => { fetchInstance(); }); } diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index 6c2ffd4742..d349b32322 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -1,9 +1,9 @@ <template> -<div class="wbrkwale"> +<div> <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> - <div v-else class="instances"> - <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance"> + <div v-else :class="$style.instances"> + <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" :class="$style.instance"> <MkInstanceCardMini :instance="instance"/> </MkA> </div> @@ -36,16 +36,14 @@ useInterval(fetch, 1000 * 60, { }); </script> -<style lang="scss" scoped> -.wbrkwale { - > .instances { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - grid-gap: 12px; +<style lang="scss" module> +.instances { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-gap: 12px; +} - > .instance:hover { - text-decoration: none; - } - } +.instance:hover { + text-decoration: none; } </style> diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index 08a29bf550..af7bc70551 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -67,7 +67,3 @@ onMounted(() => { }); }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 6a11e8b768..a3c8659ce5 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -132,7 +132,3 @@ defineExpose({ pushData, }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index 1f56a2826a..69ca89e226 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -33,9 +33,9 @@ import { markRaw, onMounted, onUnmounted, ref } from 'vue'; import XChart from './overview.queue.chart.vue'; import number from '@/filters/number'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; -const connection = markRaw(stream.useChannel('queueStats')); +const connection = markRaw(useStream().useChannel('queueStats')); const activeSincePrevTick = ref(0); const active = ref(0); diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 5c96c07bfb..e8295c81b5 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -1,6 +1,6 @@ <template> -<MkSpacer :content-max="1000"> - <div ref="rootEl" class="edbbcaef"> +<MkSpacer :contentMax="1000"> + <div ref="rootEl" :class="$style.root"> <MkFoldableSection class="item"> <template #header>Stats</template> <XStats/> @@ -72,7 +72,7 @@ import XRetention from './overview.retention.vue'; import XModerators from './overview.moderators.vue'; import XHeatmap from './overview.heatmap.vue'; import * as os from '@/os'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; @@ -87,7 +87,7 @@ let federationSubActive = $ref<number | null>(null); let federationSubActiveDiff = $ref<number | null>(null); let newUsers = $ref(null); let activeInstances = $shallowRef(null); -const queueStatsConnection = markRaw(stream.useChannel('queueStats')); +const queueStatsConnection = markRaw(useStream().useChannel('queueStats')); const now = new Date(); const filesPagination = { endpoint: 'admin/drive/files' as const, @@ -176,8 +176,8 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.edbbcaef { +<style lang="scss" module> +.root { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); grid-gap: 16px; diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue index 6ad566187a..c81f50a0d2 100644 --- a/packages/frontend/src/pages/admin/proxy-account.vue +++ b/packages/frontend/src/pages/admin/proxy-account.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo> <MkKeyValue> diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue index 1a1f6a9db4..9bc0eee212 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -132,7 +132,3 @@ defineExpose({ pushData, }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue index 100d1ea545..8e6856fddd 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -1,35 +1,35 @@ <template> -<div class="pumxzjhg _gaps"> +<div class="_gaps"> <div :class="$style.status"> - <div class="item _panel"><div class="label">Process</div>{{ number(activeSincePrevTick) }}</div> - <div class="item _panel"><div class="label">Active</div>{{ number(active) }}</div> - <div class="item _panel"><div class="label">Waiting</div>{{ number(waiting) }}</div> - <div class="item _panel"><div class="label">Delayed</div>{{ number(delayed) }}</div> + <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Process</div>{{ number(activeSincePrevTick) }}</div> + <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Active</div>{{ number(active) }}</div> + <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Waiting</div>{{ number(waiting) }}</div> + <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Delayed</div>{{ number(delayed) }}</div> </div> - <div class="charts"> - <div class="chart"> - <div class="title">Process</div> + <div :class="$style.charts"> + <div :class="$style.chart"> + <div :class="$style.chartTitle">Process</div> <XChart ref="chartProcess" type="process"/> </div> - <div class="chart"> - <div class="title">Active</div> + <div :class="$style.chart"> + <div :class="$style.chartTitle">Active</div> <XChart ref="chartActive" type="active"/> </div> - <div class="chart"> - <div class="title">Delayed</div> + <div :class="$style.chart"> + <div :class="$style.chartTitle">Delayed</div> <XChart ref="chartDelayed" type="delayed"/> </div> - <div class="chart"> - <div class="title">Waiting</div> + <div :class="$style.chart"> + <div :class="$style.chartTitle">Waiting</div> <XChart ref="chartWaiting" type="waiting"/> </div> </div> - <MkFolder :default-open="true" :max-height="250"> + <MkFolder :defaultOpen="true" :max-height="250"> <template #icon><i class="ti ti-alert-triangle"></i></template> <template #label>Errored instances</template> <template #suffix>({{ number(jobs.reduce((a, b) => a + b[1], 0)) }} jobs)</template> - - <div :class="$style.jobs"> + + <div> <div v-if="jobs.length > 0"> <div v-for="job in jobs" :key="job[0]"> <MkA :to="`/instance-info/${job[0]}`" behavior="window">{{ job[0] }}</MkA> @@ -47,11 +47,11 @@ import { markRaw, onMounted, onUnmounted, ref } from 'vue'; import XChart from './queue.chart.chart.vue'; import number from '@/filters/number'; import * as os from '@/os'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; import MkFolder from '@/components/MkFolder.vue'; -const connection = markRaw(stream.useChannel('queueStats')); +const connection = markRaw(useStream().useChannel('queueStats')); const activeSincePrevTick = ref(0); const active = ref(0); @@ -118,45 +118,36 @@ onUnmounted(() => { }); </script> -<style lang="scss" scoped> -.pumxzjhg { - > .charts { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; +<style lang="scss" module> +.charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} - > .chart { - min-width: 0; - padding: 16px; - background: var(--panel); - border-radius: var(--radius); +.chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); +} - > .title { - margin-bottom: 8px; - } - } - } +.chartTitle { + margin-bottom: 8px; } -</style> -<style lang="scss" module> .status { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-gap: 10px; +} - &:global { - > .item { - padding: 12px 16px; - - > .label { - font-size: 80%; - opacity: 0.6; - } - } - } +.statusItem { + padding: 12px 16px; } -.jobs { +.statusLabel { + font-size: 80%; + opacity: 0.6; } </style> diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue index 509d329eb1..1282a4b49f 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/queue.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <XQueue v-if="tab === 'deliver'" domain="deliver"/> <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> <br> diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index 7ebcdfc583..119439c958 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -1,14 +1,14 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <div class="_gaps"> <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;"> <div>{{ relay.inbox }}</div> - <div class="status"> - <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i> - <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i> - <i v-else class="ti ti-clock icon requesting"></i> + <div style="margin: 8px 0;"> + <i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--success);"></i> + <i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--error);"></i> + <i v-else class="ti ti-clock" :class="$style.icon"></i> <span>{{ i18n.t(`_relayStatus.${relay.status}`) }}</span> </div> <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> @@ -83,23 +83,9 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.relaycxt { - > .status { - margin: 8px 0; - - > .icon { - width: 1em; - margin-right: 0.75em; - - &.accepted { - color: var(--success); - } - - &.rejected { - color: var(--error); - } - } - } +<style lang="scss" module> +.icon { + width: 1em; + margin-right: 0.75em; } </style> diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index c211ef2f05..c7a34ac77b 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -2,12 +2,12 @@ <div> <MkStickyContainer> <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> <XEditor v-if="data" v-model="data"/> </MkSpacer> <template #footer> <div :class="$style.footer"> - <MkSpacer :content-max="600" :margin-min="16" :margin-max="16"> + <MkSpacer :contentMax="600" :marginMin="16" :marginMax="16"> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </MkSpacer> </div> diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 49942c87ce..a1fa9d2932 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -36,7 +36,7 @@ <option value="conditional">{{ i18n.ts._role.conditional }}</option> </MkSelect> - <MkFolder v-if="role.target === 'conditional'" default-open> + <MkFolder v-if="role.target === 'conditional'" defaultOpen> <template #label>{{ i18n.ts._role.condition }}</template> <div class="_gaps"> <RolesEditorFormula v-model="role.condFormula"/> @@ -81,11 +81,11 @@ <MkSwitch v-model="role.policies.rateLimitFactor.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts._role.useBaseValue }}</template> </MkSwitch> - <MkRange :model-value="role.policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => role.policies.rateLimitFactor.value = (v / 100)"> + <MkRange :modelValue="role.policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => role.policies.rateLimitFactor.value = (v / 100)"> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template> </MkRange> - <MkRange v-model="role.policies.rateLimitFactor.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.rateLimitFactor.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -105,7 +105,7 @@ <MkSwitch v-model="role.policies.gtlAvailable.value" :disabled="role.policies.gtlAvailable.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.gtlAvailable.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.gtlAvailable.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -125,7 +125,7 @@ <MkSwitch v-model="role.policies.ltlAvailable.value" :disabled="role.policies.ltlAvailable.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.ltlAvailable.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.ltlAvailable.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -145,7 +145,7 @@ <MkSwitch v-model="role.policies.canPublicNote.value" :disabled="role.policies.canPublicNote.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.canPublicNote.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.canPublicNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -165,7 +165,7 @@ <MkSwitch v-model="role.policies.canInvite.value" :disabled="role.policies.canInvite.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.canInvite.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.canInvite.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -185,7 +185,7 @@ <MkSwitch v-model="role.policies.canManageCustomEmojis.value" :disabled="role.policies.canManageCustomEmojis.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.canManageCustomEmojis.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.canManageCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -205,7 +205,7 @@ <MkSwitch v-model="role.policies.canSearchNotes.value" :disabled="role.policies.canSearchNotes.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.canSearchNotes.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.canSearchNotes.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -225,7 +225,7 @@ <MkInput v-model="role.policies.driveCapacityMb.value" :disabled="role.policies.driveCapacityMb.useDefault" type="number" :readonly="readonly"> <template #suffix>MB</template> </MkInput> - <MkRange v-model="role.policies.driveCapacityMb.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.driveCapacityMb.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -245,7 +245,7 @@ <MkSwitch v-model="role.policies.alwaysMarkNsfw.value" :disabled="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -264,7 +264,7 @@ </MkSwitch> <MkInput v-model="role.policies.pinLimit.value" :disabled="role.policies.pinLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.pinLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.pinLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -283,7 +283,7 @@ </MkSwitch> <MkInput v-model="role.policies.antennaLimit.value" :disabled="role.policies.antennaLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.antennaLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.antennaLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -303,7 +303,7 @@ <MkInput v-model="role.policies.wordMuteLimit.value" :disabled="role.policies.wordMuteLimit.useDefault" type="number" :readonly="readonly"> <template #suffix>chars</template> </MkInput> - <MkRange v-model="role.policies.wordMuteLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.wordMuteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -322,7 +322,7 @@ </MkSwitch> <MkInput v-model="role.policies.webhookLimit.value" :disabled="role.policies.webhookLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.webhookLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.webhookLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -341,7 +341,7 @@ </MkSwitch> <MkInput v-model="role.policies.clipLimit.value" :disabled="role.policies.clipLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.clipLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.clipLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -360,7 +360,7 @@ </MkSwitch> <MkInput v-model="role.policies.noteEachClipsLimit.value" :disabled="role.policies.noteEachClipsLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.noteEachClipsLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.noteEachClipsLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -379,7 +379,7 @@ </MkSwitch> <MkInput v-model="role.policies.userListLimit.value" :disabled="role.policies.userListLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.userListLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.userListLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -398,7 +398,7 @@ </MkSwitch> <MkInput v-model="role.policies.userEachUserListsLimit.value" :disabled="role.policies.userEachUserListsLimit.useDefault" type="number" :readonly="readonly"> </MkInput> - <MkRange v-model="role.policies.userEachUserListsLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.userEachUserListsLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> @@ -418,7 +418,7 @@ <MkSwitch v-model="role.policies.canHideAds.value" :disabled="role.policies.canHideAds.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - <MkRange v-model="role.policies.canHideAds.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <MkRange v-model="role.policies.canHideAds.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 6eac902577..4ed6abf200 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div class="_gaps"> <div class="_buttons"> <MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> @@ -11,9 +11,9 @@ <MkFolder> <template #icon><i class="ti ti-info-circle"></i></template> <template #label>{{ i18n.ts.info }}</template> - <XEditor :model-value="role" readonly/> + <XEditor :modelValue="role" readonly/> </MkFolder> - <MkFolder v-if="role.target === 'manual'" default-open> + <MkFolder v-if="role.target === 'manual'" defaultOpen> <template #icon><i class="ti ti-users"></i></template> <template #label>{{ i18n.ts.users }}</template> <template #suffix>{{ role.usersCount }}</template> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index e8dbe1c5f0..6634d9cba9 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div class="_gaps"> <MkFolder> <template #label>{{ i18n.ts._role.baseRole }}</template> @@ -14,7 +14,7 @@ <MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])"> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> - <MkRange :model-value="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor = (v / 100)"> + <MkRange :modelValue="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => policies.rateLimitFactor = (v / 100)"> <template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template> </MkRange> </MkFolder> @@ -156,13 +156,13 @@ <MkFoldableSection> <template #header>Manual roles</template> <div class="_gaps_s"> - <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :for-moderation="true"/> + <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :forModeration="true"/> </div> </MkFoldableSection> <MkFoldableSection> <template #header>Conditional roles</template> <div class="_gaps_s"> - <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :for-moderation="true"/> + <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :forModeration="true"/> </div> </MkFoldableSection> </div> diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index cd8ef9e68b..efb9f81f25 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> <MkFolder> @@ -33,7 +33,7 @@ <option value="remote">{{ i18n.ts.remoteOnly }}</option> </MkRadios> - <MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`"> + <MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`"> <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> </MkRange> @@ -65,7 +65,7 @@ <div class="_gaps_m"> <span>{{ i18n.ts.activeEmailValidationDescription }}</span> - <MkSwitch v-model="enableActiveEmailValidation" @update:model-value="save"> + <MkSwitch v-model="enableActiveEmailValidation" @update:modelValue="save"> <template #label>Enable</template> </MkSwitch> </div> @@ -77,7 +77,7 @@ <template v-else #suffix>Disabled</template> <div class="_gaps_m"> - <MkSwitch v-model="enableIpLogging" @update:model-value="save"> + <MkSwitch v-model="enableIpLogging" @update:modelValue="save"> <template #label>Enable</template> </MkSwitch> </div> diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index 85781c0bd0..fdba4f464e 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -2,13 +2,13 @@ <div> <MkStickyContainer> <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <div class="_gaps_m"> <div>{{ i18n.ts._serverRules.description }}</div> <Sortable v-model="serverRules" class="_gaps_m" - :item-key="(_, i) => i" + :itemKey="(_, i) => i" :animation="150" :handle="'.' + $style.itemHandle" @start="e => e.item.classList.add('active')" @@ -27,7 +27,7 @@ </Sortable> <div :class="$style.commands"> <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkButton primary rounded :class="$style.buttonSave" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> </MkSpacer> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 7ec3c381f3..39d5ae8607 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> <MkInput v-model="name"> @@ -13,7 +13,7 @@ <template #label>{{ i18n.ts.instanceDescription }}</template> </MkTextarea> - <FormSplit :min-width="300"> + <FormSplit :minWidth="300"> <MkInput v-model="maintainerName"> <template #label>{{ i18n.ts.maintainerName }}</template> </MkInput> @@ -30,18 +30,6 @@ </MkTextarea> <FormSection> - <div class="_gaps_s"> - <MkSwitch v-model="enableChartsForRemoteUser"> - <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> - </MkSwitch> - - <MkSwitch v-model="enableChartsForFederatedInstances"> - <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template> - </MkSwitch> - </div> - </FormSection> - - <FormSection> <template #label>{{ i18n.ts.theme }}</template> <div class="_gaps_m"> @@ -128,7 +116,7 @@ </MkSpacer> <template #footer> <div :class="$style.footer"> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </MkSpacer> </div> @@ -166,8 +154,6 @@ let defaultDarkTheme: any = $ref(null); let pinnedUsers: string = $ref(''); let cacheRemoteFiles: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false); -let enableChartsForRemoteUser: boolean = $ref(false); -let enableChartsForFederatedInstances: boolean = $ref(false); let swPublicKey: any = $ref(null); let swPrivateKey: any = $ref(null); let deeplAuthKey: string = $ref(''); @@ -188,8 +174,6 @@ async function init() { pinnedUsers = meta.pinnedUsers.join('\n'); cacheRemoteFiles = meta.cacheRemoteFiles; enableServiceWorker = meta.enableServiceWorker; - enableChartsForRemoteUser = meta.enableChartsForRemoteUser; - enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; swPublicKey = meta.swPublickey; swPrivateKey = meta.swPrivateKey; deeplAuthKey = meta.deeplAuthKey; @@ -211,8 +195,6 @@ function save() { pinnedUsers: pinnedUsers.split('\n'), cacheRemoteFiles, enableServiceWorker, - enableChartsForRemoteUser, - enableChartsForFederatedInstances, swPublicKey, swPrivateKey, deeplAuthKey, diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 819ced826d..1af661a475 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900"> + <MkSpacer :contentMax="900"> <div class="_gaps"> <div :class="$style.inputs"> <MkSelect v-model="sort" style="flex: 1;"> @@ -28,11 +28,11 @@ </MkSelect> </div> <div :class="$style.inputs"> - <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()"> <template #prefix>@</template> <template #label>{{ i18n.ts.username }}</template> </MkInput> - <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()"> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> <template #prefix>@</template> <template #label>{{ i18n.ts.host }}</template> </MkInput> diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue index 728ef3c0b1..4cf2e4b2e5 100644 --- a/packages/frontend/src/pages/ads.vue +++ b/packages/frontend/src/pages/ads.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader/></template> - <MkSpacer :content-max="500"> + <MkSpacer :contentMax="500"> <div class="_gaps"> <MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/> </div> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 16a0ee8373..3dfb9e5554 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m"> <section v-for="(announcement, i) in items" :key="announcement.id" class="announcement _panel"> <div class="header"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 62e8178af1..a22714791f 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -1,19 +1,20 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <div ref="rootEl" v-hotkey.global="keymap" class="tqmomfks"> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div class="tl"> - <MkTimeline - ref="tlEl" :key="antennaId" - class="tl" - src="antenna" - :antenna="antennaId" - :sound="true" - @queue="queueUpdated" - /> + <MkSpacer :contentMax="800"> + <div ref="rootEl" v-hotkey.global="keymap"> + <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div :class="$style.tl"> + <MkTimeline + ref="tlEl" :key="antennaId" + src="antenna" + :antenna="antennaId" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> - </div> + </MkSpacer> </MkStickyContainer> </template> @@ -89,36 +90,29 @@ definePageMetadata(computed(() => antenna ? { } : null)); </script> -<style lang="scss" scoped> -.tqmomfks { - padding: var(--margin); +<style lang="scss" module> +.new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + margin: calc(-0.675em - 8px) 0; - > .new { - position: sticky; - top: calc(var(--stickyTop, 0px) + 16px); - z-index: 1000; - width: 100%; - margin: calc(-0.675em - 8px - var(--margin)) 0 calc(-0.675em - 8px); - - > button { - display: block; - margin: var(--margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; - } + &:first-child { + margin-top: calc(-0.675em - 8px - var(--margin)); } +} - > .tl { - background: var(--bg); - border-radius: var(--radius); - overflow: clip; - } +.newButton { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; } -@container (min-width: 800px) { - .tqmomfks { - max-width: 800px; - margin: 0 auto; - } +.tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; } </style> diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue index 7d2828e91d..3a3cb3d7d3 100644 --- a/packages/frontend/src/pages/api-console.vue +++ b/packages/frontend/src/pages/api-console.vue @@ -1,10 +1,10 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div class="_gaps_m"> <div class="_gaps_m"> - <MkInput v-model="endpoint" :datalist="endpoints" @update:model-value="onEndpointChange()"> + <MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()"> <template #label>Endpoint</template> </MkInput> <MkTextarea v-model="body" code> diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 2f40e7ded6..54e76805bf 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="500"> + <MkSpacer :contentMax="500"> <div v-if="state == 'fetch-session-error'"> <p>{{ i18n.ts.somethingHappened }}</p> </div> diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index a74ab40473..0a358a141b 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div v-if="channelId == null || channel != null" class="_gaps_m"> <MkInput v-model="name"> <template #label>{{ i18n.ts.name }}</template> @@ -23,7 +23,7 @@ </div> </div> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.pinnedNotes }}</template> <div class="_gaps"> @@ -31,7 +31,7 @@ <Sortable v-model="pinnedNotes" - item-key="id" + itemKey="id" :handle="'.' + $style.pinnedNoteHandle" :animation="150" > diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 9aa564a7da..bcc0fc6860 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -1,10 +1,12 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :class="$style.main"> + <MkSpacer :contentMax="700" :class="$style.main"> <div v-if="channel && tab === 'overview'" class="_gaps"> <div class="_panel" :class="$style.bannerContainer"> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton> <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" :class="$style.banner"> <div :class="$style.bannerStatus"> <div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> @@ -13,13 +15,10 @@ <div :class="$style.bannerFade"></div> </div> <div v-if="channel.description" :class="$style.description"> - <Mfm :text="channel.description" :is-note="false" :i="$i"/> + <Mfm :text="channel.description" :isNote="false" :i="$i"/> </div> </div> - <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-star"></i></MkButton> - <MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-star"></i></MkButton> - <MkFoldableSection> <template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> <div v-if="channel.pinnedNotes.length > 0" class="_gaps"> @@ -52,7 +51,7 @@ </MkSpacer> <template #footer> <div :class="$style.footer"> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <div class="_buttonsCenter"> <MkButton inline rounded primary gradate @click="openPostForm()"><i class="ti ti-pencil"></i> {{ i18n.ts.postToTheChannel }}</MkButton> </div> @@ -229,6 +228,13 @@ definePageMetadata(computed(() => channel ? { left: 16px; } +.favorite { + position: absolute; + z-index: 1; + top: 16px; + right: 16px; +} + .banner { position: relative; height: 200px; diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index e670cdd864..0c4ccc1bcd 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -1,13 +1,13 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div v-if="tab === 'search'"> <div class="_gaps"> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkRadios v-model="searchType" @update:model-value="search()"> + <MkRadios v-model="searchType" @update:modelValue="search()"> <option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option> <option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option> </MkRadios> diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index 24eae32e13..69ecc9e772 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <MkClickerGame/> </MkSpacer> </MkStickyContainer> diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index e3ac3f4c9b..d5313099da 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -1,16 +1,16 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions"/></template> - <MkSpacer :content-max="800"> - <div v-if="clip"> - <div class="okzinsic _panel"> - <div v-if="clip.description" class="description"> - <Mfm :text="clip.description" :is-note="false" :i="$i"/> + <MkSpacer :contentMax="800"> + <div v-if="clip" class="_gaps"> + <div class="_panel"> + <div v-if="clip.description" :class="$style.description"> + <Mfm :text="clip.description" :isNote="false" :i="$i"/> </div> - <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> - <div class="user"> - <MkAvatar :user="clip.user" class="avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + <div :class="$style.user"> + <MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> </div> </div> @@ -147,25 +147,20 @@ definePageMetadata(computed(() => clip ? { } : null)); </script> -<style lang="scss" scoped> -.okzinsic { - position: relative; - margin-bottom: var(--margin); - - > .description { - padding: 16px; - } +<style lang="scss" module> +.description { + padding: 16px; +} - > .user { - $height: 32px; - padding: 16px; - border-top: solid 0.5px var(--divider); - line-height: $height; +.user { + --height: 32px; + padding: 16px; + border-top: solid 0.5px var(--divider); + line-height: var(--height); +} - > .avatar { - width: $height; - height: $height; - } - } +.avatar { + width: var(--height); + height: var(--height); } </style> diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 3f13f0787d..3da6a0d9cb 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900"> + <MkSpacer :contentMax="900"> <div class="ogwlenmc"> <div v-if="tab === 'local'" class="local"> <MkInput v-model="query" :debounce="true" type="search"> @@ -123,15 +123,14 @@ const toggleSelect = (emoji) => { }; const add = async (ev: MouseEvent) => { - const files = await selectFiles(ev.currentTarget ?? ev.target, null); - - const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { - fileId: file.id, - }))); - promise.then(() => { - emojisPaginationComponent.value.reload(); - }); - os.promiseDialog(promise); + os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + }, { + done: result => { + if (result.created) { + emojisPaginationComponent.value.prepend(result.created); + } + }, + }, 'closed'); }; const edit = (emoji) => { diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 84bc153b71..3208c92738 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -1,84 +1,171 @@ <template> <MkModalWindow ref="dialog" - :width="370" - :with-ok-button="true" - @close="$refs.dialog.close()" + :width="400" + @close="dialog.close()" @closed="$emit('closed')" - @ok="ok()" > - <template #header>:{{ emoji.name }}:</template> + <template v-if="emoji" #header>:{{ emoji.name }}:</template> + <template v-else #header>New emoji</template> - <MkSpacer :margin-min="20" :margin-max="28"> - <div class="yigymqpb _gaps_m"> - <img :src="`/emoji/${emoji.name}.webp`" class="img"/> - <MkInput v-model="name"> - <template #label>{{ i18n.ts.name }}</template> - </MkInput> - <MkInput v-model="category" :datalist="customEmojiCategories"> - <template #label>{{ i18n.ts.category }}</template> - </MkInput> - <MkInput v-model="aliases"> - <template #label>{{ i18n.ts.tags }}</template> - <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> - </MkInput> - <MkInput v-model="license"> - <template #label>{{ i18n.ts.license }}</template> - </MkInput> - <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <div> + <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_gaps_m"> + <div v-if="imgUrl != null" :class="$style.imgs"> + <div style="background: #000;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + <div style="background: #222;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + <div style="background: #ddd;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + <div style="background: #fff;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + </div> + <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> + <MkInput v-model="name"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkInput v-model="category" :datalist="customEmojiCategories"> + <template #label>{{ i18n.ts.category }}</template> + </MkInput> + <MkInput v-model="aliases"> + <template #label>{{ i18n.ts.tags }}</template> + <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkInput v-model="license"> + <template #label>{{ i18n.ts.license }}</template> + </MkInput> + <MkFolder> + <template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template> + <template #suffix>{{ rolesThatCanBeUsedThisEmojiAsReaction.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisEmojiAsReaction.length }}</template> + + <div class="_gaps"> + <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <div v-for="role in rolesThatCanBeUsedThisEmojiAsReaction" :key="role.id" :class="$style.roleItem"> + <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(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> + + <MkInfo>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription }}</MkInfo> + <MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo> + </div> + </MkFolder> + <MkSwitch v-model="isSensitive">isSensitive</MkSwitch> + <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> + <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </MkSpacer> + <div :class="$style.footer"> + <MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> </div> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed, watch } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { customEmojiCategories } from '@/custom-emojis'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { selectFile, selectFiles } from '@/scripts/select-file'; +import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ - emoji: any, + emoji?: any, }>(); let dialog = $ref(null); -let name: string = $ref(props.emoji.name); -let category: string = $ref(props.emoji.category); -let aliases: string = $ref(props.emoji.aliases.join(' ')); -let license: string = $ref(props.emoji.license ?? ''); +let name: string = $ref(props.emoji ? props.emoji.name : ''); +let category: string = $ref(props.emoji ? props.emoji.category : ''); +let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : ''); +let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : ''); +let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false); +let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); +let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); +let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); +let file = $ref(); + +watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => { + rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); +}, { immediate: true }); + +const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); const emit = defineEmits<{ - (ev: 'done', v: { deleted?: boolean, updated?: any }): void, + (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, (ev: 'closed'): void }>(); -function ok() { - update(); +async function changeImage(ev) { + file = await selectFile(ev.currentTarget ?? ev.target, null); } -async function update() { - await os.apiWithDialog('admin/emoji/update', { - id: props.emoji.id, +async function addRole() { + const roles = await os.api('admin/roles/list'); + const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id); + + const { canceled, result: role } = await os.select({ + items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), + }); + if (canceled) return; + + rolesThatCanBeUsedThisEmojiAsReaction.push(role); +} + +async function removeRole(role, ev) { + rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); +} + +async function done() { + const params = { name, - category, - aliases: aliases.split(' '), + category: category === '' ? null : category, + aliases: aliases.split(' ').filter(x => x !== ''), license: license === '' ? null : license, - }); + isSensitive, + localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id), + }; + + if (file) { + params.fileId = file.id; + } - emit('done', { - updated: { + if (props.emoji) { + await os.apiWithDialog('admin/emoji/update', { id: props.emoji.id, - name, - category, - aliases: aliases.split(' '), - license: license === '' ? null : license, - }, - }); + ...params, + }); + + emit('done', { + updated: { + id: props.emoji.id, + ...params, + }, + }); + + dialog.close(); + } else { + const created = await os.apiWithDialog('admin/emoji/add', params); + + emit('done', { + created: created, + }); - dialog.close(); + dialog.close(); + } } async function del() { @@ -99,12 +186,48 @@ async function del() { } </script> -<style lang="scss" scoped> -.yigymqpb { - > .img { - display: block; - height: 64px; - margin: 0 auto; - } +<style lang="scss" module> +.imgs { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.imgContainer { + padding: 8px; + border-radius: 6px; +} + +.img { + display: block; + height: 64px; + width: 64px; + object-fit: contain; +} + +.roleItem { + display: flex; +} + +.role { + flex: 1; +} + +.roleUnassign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} + +.footer { + position: sticky; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--divider); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); } </style> diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index bdd21b29ee..e9fab6a313 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -1,9 +1,9 @@ <template> -<button class="zuvgdzyu _button" @click="menu"> - <img :src="emoji.url" class="img" loading="lazy"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.aliases.join(' ') }}</div> +<button class="_button" :class="$style.root" @click="menu"> + <img :src="emoji.url" :class="$style.img" loading="lazy"/> + <div :class="$style.body"> + <div :class="$style.name" class="_monospace">{{ emoji.name }}</div> + <div :class="$style.info">{{ emoji.aliases.join(' ') }}</div> </div> </button> </template> @@ -49,8 +49,8 @@ function menu(ev) { } </script> -<style lang="scss" scoped> -.zuvgdzyu { +<style lang="scss" module> +.root { display: flex; align-items: center; padding: 12px; @@ -61,29 +61,29 @@ function menu(ev) { &:hover { border-color: var(--accent); } +} - > .img { - width: 42px; - height: 42px; - object-fit: contain; - } +.img { + width: 42px; + height: 42px; + object-fit: contain; +} - > .body { - padding: 0 0 0 8px; - white-space: nowrap; - overflow: hidden; +.body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; +} - > .name { - text-overflow: ellipsis; - overflow: hidden; - } +.name { + text-overflow: ellipsis; + overflow: hidden; +} - > .info { - opacity: 0.5; - font-size: 0.9em; - text-overflow: ellipsis; - overflow: hidden; - } - } +.info { + opacity: 0.5; + font-size: 0.9em; + text-overflow: ellipsis; + overflow: hidden; } </style> diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index a972ae04ec..5c71313738 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -1,5 +1,5 @@ <template> -<MkSpacer :content-max="800"> +<MkSpacer :contentMax="800"> <MkTab v-model="tab" style="margin-bottom: var(--margin);"> <option value="notes">{{ i18n.ts.notes }}</option> <option value="polls">{{ i18n.ts.poll }}</option> diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue index 6ac469f7ba..c855d79f45 100644 --- a/packages/frontend/src/pages/explore.roles.vue +++ b/packages/frontend/src/pages/explore.roles.vue @@ -1,7 +1,7 @@ <template> -<MkSpacer :content-max="700"> +<MkSpacer :contentMax="700"> <div class="_gaps_s"> - <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="false"/> + <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :forModeration="false"/> </div> </MkSpacer> </template> diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index f9c833dd29..785dbaa343 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -1,24 +1,24 @@ <template> -<MkSpacer :content-max="1200"> +<MkSpacer :contentMax="1200"> <MkTab v-model="origin" style="margin-bottom: var(--margin);"> <option value="local">{{ i18n.ts.local }}</option> <option value="remote">{{ i18n.ts.remote }}</option> </MkTab> <div v-if="origin === 'local'"> <template v-if="tag == null"> - <MkFoldableSection class="_margin" persist-key="explore-pinned-users"> + <MkFoldableSection class="_margin" persistKey="explore-pinned-users"> <template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template> <MkUserList :pagination="pinnedUsers"/> </MkFoldableSection> - <MkFoldableSection class="_margin" persist-key="explore-popular-users"> + <MkFoldableSection class="_margin" persistKey="explore-popular-users"> <template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> <MkUserList :pagination="popularUsers"/> </MkFoldableSection> - <MkFoldableSection class="_margin" persist-key="explore-recently-updated-users"> + <MkFoldableSection class="_margin" persistKey="explore-recently-updated-users"> <template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> <MkUserList :pagination="recentlyUpdatedUsers"/> </MkFoldableSection> - <MkFoldableSection class="_margin" persist-key="explore-recently-registered-users"> + <MkFoldableSection class="_margin" persistKey="explore-recently-registered-users"> <template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template> <MkUserList :pagination="recentlyRegisteredUsers"/> </MkFoldableSection> diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index 0dc9b9dc8f..460bf65d1e 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> @@ -11,7 +11,7 @@ </template> <template #default="{ items }"> - <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> + <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false"> <MkNote :key="item.id" :note="item.note" :class="$style.note"/> </MkDateSeparatedList> </template> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 816825e5b6..6a16cd1c4a 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div class="_gaps"> <MkInput v-model="title"> <template #label>{{ i18n.ts._play.title }}</template> @@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkInput from '@/components/MkInput.vue'; import { useRouter } from '@/router'; -const PRESET_DEFAULT = `/// @ 0.13.2 +const PRESET_DEFAULT = `/// @ 0.13.3 var name = "" @@ -51,7 +51,7 @@ Ui:render([ ]) `; -const PRESET_OMIKUJI = `/// @ 0.13.2 +const PRESET_OMIKUJI = `/// @ 0.13.3 // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -94,7 +94,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.13.2 +const PRESET_SHUFFLE = `/// @ 0.13.3 // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -173,7 +173,7 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.13.2 +const PRESET_QUIZ = `/// @ 0.13.3 let title = '地理クイズ' let qas = [{ @@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({ Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.13.2 +const PRESET_TIMELINE = `/// @ 0.13.3 // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { @@ -442,7 +442,3 @@ definePageMetadata(computed(() => flash ? { title: i18n.ts._play.new, })); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index f1dca5f240..1f933c2346 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -1,30 +1,30 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div v-if="tab === 'featured'" class=""> + <MkSpacer :contentMax="700"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> <div class="_gaps_s"> - <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> + <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'my'" class="my"> + <div v-else-if="tab === 'my'"> <div class="_gaps"> - <MkButton class="new" gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> <div class="_gaps_s"> - <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> + <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> </div> </MkPagination> </div> </div> - <div v-else-if="tab === 'liked'" class=""> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedFlashsPagination"> <div class="_gaps_s"> - <MkFlashPreview v-for="like in items" :key="like.flash.id" class="" :flash="like.flash"/> + <MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/> </div> </MkPagination> </div> @@ -87,21 +87,3 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-player-play', }))); </script> - -<style lang="scss" scoped> -.rknalgpo { - &.my .ckltabjg:first-child { - margin-top: 16px; - } - - .ckltabjg:not(:last-child) { - margin-bottom: 8px; - } - - @media (min-width: 500px) { - .ckltabjg:not(:last-child) { - margin-bottom: 16px; - } - } -} -</style> diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 961ef4b751..2e1532b9f3 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="flash" :key="flash.id"> <Transition :name="defaultStore.state.animation ? 'zoom' : ''" mode="out-in"> @@ -10,8 +10,8 @@ <MkAsUi v-if="root" :component="root" :components="components"/> </div> <div class="actions _panel"> - <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" as-like class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts.like" as-like class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> + <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> <MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> </div> @@ -27,7 +27,7 @@ </div> </div> </Transition> - <MkFolder :default-open="false" :max-height="280" class="_margin"> + <MkFolder :defaultOpen="false" :max-height="280" class="_margin"> <template #icon><i class="ti ti-code"></i></template> <template #label>{{ i18n.ts._play.viewSource }}</template> diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index a51d1c78a4..1452942a1e 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <MkPagination ref="paginationComponent" :pagination="pagination"> <template #empty> <div class="_fullinfo"> diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue index 828246d678..d14b663364 100644 --- a/packages/frontend/src/pages/follow.vue +++ b/packages/frontend/src/pages/follow.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-follow-page"> +<div> </div> </template> diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index cafcee0c33..f381636a78 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <FormSuspense :p="init" class="_gaps"> <MkInput v-model="title"> <template #label>{{ i18n.ts.title }}</template> diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index fc9cc7ae9e..3c9c21a2ff 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -1,21 +1,21 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="1400"> + <MkSpacer :contentMax="1400"> <div class="_root"> <div v-if="tab === 'explore'"> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> - <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> - <div class="vfpdbgtk"> + <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true"> + <div :class="$style.items"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> </div> </MkPagination> </MkFoldableSection> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template> - <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> - <div class="vfpdbgtk"> + <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disableAutoLoad="true"> + <div :class="$style.items"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> </div> </MkPagination> @@ -23,7 +23,7 @@ </div> <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> - <div class="vfpdbgtk"> + <div :class="$style.items"> <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> </div> </MkPagination> @@ -31,7 +31,7 @@ <div v-else-if="tab === 'my'"> <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA> <MkPagination v-slot="{items}" :pagination="myPostsPagination"> - <div class="vfpdbgtk"> + <div :class="$style.items"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> </div> </MkPagination> @@ -119,15 +119,11 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.vfpdbgtk { +<style lang="scss" module> +.items { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-gap: 12px; margin: 0 var(--margin); - - > .post { - - } } </style> diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index e0f3c105e1..dfa6c0bac0 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="1000" :marginMin="16" :marginMax="32"> <div class="_root"> <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="post" class="rkxwuolj"> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index ba5fda137a..83997b2555 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32"> + <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32"> <div v-if="tab === 'overview'" class="_gaps_m"> <div class="fnfelxur"> <img :src="faviconUrl" alt="" class="icon"/> @@ -29,8 +29,8 @@ <FormSection v-if="iAmModerator"> <template #label>Moderation</template> <div class="_gaps_s"> - <MkSwitch v-model="suspended" @update:model-value="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> - <MkSwitch v-model="isBlocked" @update:model-value="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> + <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> + <MkSwitch v-model="isBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> </div> </FormSection> diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue new file mode 100644 index 0000000000..f92c06d1c5 --- /dev/null +++ b/packages/frontend/src/pages/list.vue @@ -0,0 +1,148 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200"> + <div :class="$style.root"> + <img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p :class="$style.text"> + <i class="ti ti-alert-triangle"></i> + {{ i18n.ts.nothing }} + </p> + </div> + </MKSpacer> + <MkSpacer v-else-if="list" :contentMax="700" :class="$style.main"> + <div v-if="list" class="members _margin"> + <div :class="$style.member_text">{{ i18n.ts.members }}</div> + <div class="_gaps_s"> + <div v-for="user in users" :key="user.id" :class="$style.userItem"> + <MkA :class="$style.userItemBody" :to="`${userPage(user)}`"> + <MkUserCardMini :user="user"/> + </MkA> + </div> + </div> + </div> + <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton> + <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton> + <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { watch, computed } from 'vue'; +import * as os from '@/os'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkButton from '@/components/MkButton.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + listId: string; +}>(); + +let list = $ref(null); +let error = $ref(); +let users = $ref([]); + +function fetchList(): void { + os.api('users/lists/show', { + listId: props.listId, + forPublic: true, + }).then(_list => { + list = _list; + os.api('users/show', { + userIds: list.userIds, + }).then(_users => { + users = _users; + }); + }).catch(err => { + error = err; + }); +} + +function like() { + os.apiWithDialog('users/lists/favorite', { + listId: list.id, + }).then(() => { + list.isLiked = true; + list.likedCount++; + }); +} + +function unlike() { + os.apiWithDialog('users/lists/unfavorite', { + listId: list.id, + }).then(() => { + list.isLiked = false; + list.likedCount--; + }); +} + +async function create() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.enterListName, + }); + if (canceled) return; + await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: list.id }); +} + +watch(() => props.listId, fetchList, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'ti ti-list', +} : null)); +</script> +<style lang="scss" module> +.main { + min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px))); +} + +.userItem { + display: flex; +} + +.userItemBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +.member_text { + margin: 5px; +} + +.root { + padding: 32px; + text-align: center; + align-items: center; +} + +.text { + margin: 0 0 8px 0; +} + +.img { + vertical-align: bottom; + width: 128px; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} + +.button { + margin-right: 10px; +} + +.import { + margin-right: 4px; +} +</style> diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index 8e0624f555..553946cd9e 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <div v-if="$i"> <div v-if="state == 'waiting'"> <MkLoading/> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index c35af3e22a..355d18fdb5 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -1,5 +1,5 @@ <template> -<div class="geegznzt"> +<div> <XAntenna :antenna="draft" @created="onAntennaCreated"/> </div> </template> @@ -38,7 +38,3 @@ definePageMetadata({ icon: 'ti ti-antenna', }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index 913fbde8e9..da9b2de48f 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -36,7 +36,3 @@ definePageMetadata({ icon: 'ti ti-antenna', }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue index 26b7bcc71b..ed92208c42 100644 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -1,6 +1,6 @@ <template> -<MkSpacer :content-max="700"> - <div class="shaynizk"> +<MkSpacer :contentMax="700"> + <div> <div class="_gaps_m"> <MkInput v-model="name"> <template #label>{{ i18n.ts.name }}</template> @@ -33,7 +33,7 @@ <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> <MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch> </div> - <div class="actions"> + <div :class="$style.actions"> <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> @@ -128,12 +128,10 @@ function addUser() { } </script> -<style lang="scss" scoped> -.shaynizk { - > .actions { - margin-top: 16px; - padding: 24px 0; - border-top: solid 0.5px var(--divider); - } +<style lang="scss" module> +.actions { + margin-top: 16px; + padding: 24px 0; + border-top: solid 0.5px var(--divider); } </style> diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index f1764b1aad..2ca026b9a1 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -1,18 +1,20 @@ -<template><MkStickyContainer> +<template> +<MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div class="ieepwinx"> - <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkSpacer :contentMax="700"> + <div class="ieepwinx"> + <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <div class=""> - <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> - <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> - <div class="name">{{ antenna.name }}</div> - </MkA> - </MkPagination> + <div class=""> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> + </MkPagination> + </div> </div> - </div> -</MkSpacer></MkStickyContainer> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index ccffa7b563..a769f8ee97 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div v-if="tab === 'my'" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index 47437f3e57..cee241c489 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -1,15 +1,17 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div class="qkcjvfiv"> - <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> + <MkSpacer :contentMax="700"> + <div class="_gaps"> + <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists"> - <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`"> - <div class="name">{{ list.name }}</div> - <MkAvatars :user-ids="list.userIds"/> - </MkA> + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination"> + <div class="_gaps"> + <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> + <div style="margin-bottom: 4px;">{{ list.name }}</div> + <MkAvatars :userIds="list.userIds"/> + </MkA> + </div> </MkPagination> </div> </MkSpacer> @@ -58,28 +60,17 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.qkcjvfiv { - > .add { - margin: 0 auto var(--margin) auto; - } - - > .lists { - > .list { - display: block; - padding: 16px; - border: solid 1px var(--divider); - border-radius: 6px; - - &:hover { - border: solid 1px var(--accent); - text-decoration: none; - } +<style lang="scss" module> +.list { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + margin-bottom: 8px; - > .name { - margin-bottom: 4px; - } - } + &:hover { + border: solid 1px var(--accent); + text-decoration: none; } } </style> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 86201e8e0c..dd431e8dc0 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -1,35 +1,43 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700" :class="$style.main"> - <div v-if="list" class="members _margin"> - <div class="">{{ i18n.ts.members }}</div> - <div class="_gaps_s"> - <div v-for="user in users" :key="user.id" :class="$style.userItem"> - <MkA :class="$style.userItemBody" :to="`${userPage(user)}`"> - <MkUserCardMini :user="user"/> - </MkA> - <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button> + <MkSpacer :contentMax="700" :class="$style.main"> + <div v-if="list" class="_gaps"> + <MkFolder> + <template #label>{{ i18n.ts.settings }}</template> + + <div class="_gaps"> + <MkInput v-model="name"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkSwitch v-model="isPublic">{{ i18n.ts.public }}</MkSwitch> + <div class="_buttons"> + <MkButton rounded primary @click="updateSettings">{{ i18n.ts.save }}</MkButton> + <MkButton rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton> + </div> </div> - </div> - </div> - </MkSpacer> - <template #footer> - <div :class="$style.footer"> - <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> - <div class="_buttons"> - <MkButton inline rounded primary @click="addUser()">{{ i18n.ts.addUser }}</MkButton> - <MkButton inline rounded @click="renameList()">{{ i18n.ts.rename }}</MkButton> - <MkButton inline rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton> + </MkFolder> + + <MkFolder defaultOpen> + <template #label>{{ i18n.ts.members }}</template> + + <div class="_gaps_s"> + <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> + <div v-for="user in users" :key="user.id" :class="$style.userItem"> + <MkA :class="$style.userItemBody" :to="`${userPage(user)}`"> + <MkUserCardMini :user="user"/> + </MkA> + <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button> + </div> </div> - </MkSpacer> + </MkFolder> </div> - </template> + </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { mainRouter } from '@/router'; @@ -37,6 +45,9 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { userPage } from '@/filters/user'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkInput from '@/components/MkInput.vue'; import { userListsCache } from '@/cache'; const props = defineProps<{ @@ -45,12 +56,17 @@ const props = defineProps<{ let list = $ref(null); let users = $ref([]); +const isPublic = ref(false); +const name = ref(''); function fetchList() { os.api('users/lists/show', { listId: props.listId, }).then(_list => { list = _list; + name.value = list.name; + isPublic.value = list.isPublic; + os.api('users/show', { userIds: list.userIds, }).then(_users => { @@ -86,23 +102,6 @@ async function removeUser(user, ev) { }], ev.currentTarget ?? ev.target); } -async function renameList() { - const { canceled, result: name } = await os.inputText({ - title: i18n.ts.enterListName, - default: list.name, - }); - if (canceled) return; - - await os.api('users/lists/update', { - listId: list.id, - name: name, - }); - - userListsCache.delete(); - - list.name = name; -} - async function deleteList() { const { canceled } = await os.confirm({ type: 'warning', @@ -117,6 +116,19 @@ async function deleteList() { mainRouter.push('/my/lists'); } +async function updateSettings() { + await os.apiWithDialog('users/lists/update', { + listId: list.id, + name: name.value, + isPublic: isPublic.value, + }); + + userListsCache.delete(); + + list.name = name.value; + list.isPublic = isPublic.value; +} + watch(() => props.listId, fetchList, { immediate: true }); const headerActions = $computed(() => []); diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index e58e44ef79..2c9d949017 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -1,5 +1,5 @@ <template> -<div class="ipledcug"> +<div> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> <div>{{ i18n.ts.notFoundDescription }}</div> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index d9baa1096a..c519cefbaf 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -1,33 +1,33 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> - <div class="fcuexfpr"> + <MkSpacer :contentMax="800"> + <div> <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="note" class="note"> + <div v-if="note"> <div v-if="showNext" class="_margin"> - <MkNotes class="" :pagination="nextPagination" :no-gap="true"/> + <MkNotes class="" :pagination="nextPagination" :noGap="true"/> </div> - <div class="main _margin"> - <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> - <div class="note _margin _gaps_s"> + <div class="_margin"> + <MkButton v-if="!showNext && hasNext" :class="$style.loadNext" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> + <div class="_margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <MkNoteDetailed :key="note.id" v-model:note="note" class="note"/> + <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note"/> </div> - <div v-if="clips && clips.length > 0" class="clips _margin"> - <div class="title">{{ i18n.ts.clip }}</div> + <div v-if="clips && clips.length > 0" class="_margin"> + <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> <div class="_gaps"> <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`"> <MkClipPreview :clip="item"/> </MkA> </div> </div> - <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton> + <MkButton v-if="!showPrev && hasPrev" :class="$style.loadPrev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton> </div> <div v-if="showPrev" class="_margin"> - <MkNotes class="" :pagination="prevPagination" :no-gap="true"/> + <MkNotes class="" :pagination="prevPagination" :noGap="true"/> </div> </div> <MkError v-else-if="error" @retry="fetchNote()"/> @@ -137,7 +137,7 @@ definePageMetadata(computed(() => note ? { } : null)); </script> -<style lang="scss" scoped> +<style lang="scss" module> .fade-enter-active, .fade-leave-active { transition: opacity 0.125s ease; @@ -147,39 +147,23 @@ definePageMetadata(computed(() => note ? { opacity: 0; } -.fcuexfpr { - background: var(--bg); - - > .note { - > .main { - > .load { - min-width: 0; - margin: 0 auto; - border-radius: 999px; - - &.next { - margin-bottom: var(--margin); - } +.loadNext, +.loadPrev { + min-width: 0; + margin: 0 auto; + border-radius: 999px; +} - &.prev { - margin-top: var(--margin); - } - } +.loadNext { + margin-bottom: var(--margin); +} - > .note { - > .note { - border-radius: var(--radius); - background: var(--panel); - } - } +.loadPrev { + margin-top: var(--margin); +} - > .clips { - > .title { - font-weight: bold; - padding: 12px; - } - } - } - } +.note { + border-radius: var(--radius); + background: var(--panel); } </style> diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 1789606cd8..8196f91868 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -1,9 +1,9 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <div v-if="tab === 'all'"> - <XNotifications class="notifications" :include-types="includeTypes"/> + <XNotifications class="notifications" :includeTypes="includeTypes"/> </div> <div v-else-if="tab === 'mentions'"> <MkNotes :pagination="mentionsPagination"/> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index 1b292e8f3c..eca3feda62 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -8,8 +8,8 @@ </button> </template> - <section class="oyyftmcf"> - <MkDriveFileThumbnail v-if="file" class="preview" :file="file" fit="contain" @click="choose()"/> + <section> + <MkDriveFileThumbnail v-if="file" style="height: 150px;" :file="file" fit="contain" @click="choose()"/> </section> </XContainer> </template> @@ -54,11 +54,3 @@ onMounted(async () => { } }); </script> - -<style lang="scss" scoped> -.oyyftmcf { - > .preview { - height: 150px; - } -} -</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index bf21ae3c67..3b15c17747 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -3,8 +3,8 @@ <XContainer :draggable="true" @remove="() => $emit('remove')"> <template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template> - <section class="vckmsadr"> - <textarea v-model="text"></textarea> + <section> + <textarea v-model="text" :class="$style.textarea"></textarea> </section> </XContainer> </template> @@ -33,23 +33,21 @@ watch($$(text), () => { }); </script> -<style lang="scss" scoped> -.vckmsadr { - > textarea { - display: block; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: 100%; - min-width: 100%; - min-height: 150px; - border: none; - box-shadow: none; - padding: 16px; - background: transparent; - color: var(--fg); - font-size: 14px; - box-sizing: border-box; - } +<style lang="scss" module> +.textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + box-sizing: border-box; } </style> diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue index 97bdcfe80f..fc945b3d63 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -1,57 +1,59 @@ <template> -<Sortable :model-value="modelValue" tag="div" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swap-threshold="0.5" @update:model-value="v => $emit('update:modelValue', v)"> +<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => $emit('update:modelValue', v)"> <template #item="{element}"> <div :class="$style.item"> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <component :is="'x-' + element.type" :model-value="element" @update:model-value="updateItem" @remove="() => removeItem(element)"/> + <component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/> </div> </template> </Sortable> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; import XSection from './els/page-editor.el.section.vue'; import XText from './els/page-editor.el.text.vue'; import XImage from './els/page-editor.el.image.vue'; import XNote from './els/page-editor.el.note.vue'; -export default defineComponent({ - components: { - Sortable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - XSection, XText, XImage, XNote, - }, +function getComponent(type: string) { + switch (type) { + case 'section': return XSection; + case 'text': return XText; + case 'image': return XImage; + case 'note': return XNote; + default: return null; + } +} - props: { - modelValue: { - type: Array, - required: true, - }, - }, +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - emits: ['update:modelValue'], +const props = defineProps<{ + modelValue: any[]; +}>(); - methods: { - updateItem(v) { - const i = this.modelValue.findIndex(x => x.id === v.id); - const newValue = [ - ...this.modelValue.slice(0, i), - v, - ...this.modelValue.slice(i + 1), - ]; - this.$emit('update:modelValue', newValue); - }, +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any[]): void; +}>(); - removeItem(el) { - const i = this.modelValue.findIndex(x => x.id === el.id); - const newValue = [ - ...this.modelValue.slice(0, i), - ...this.modelValue.slice(i + 1), - ]; - this.$emit('update:modelValue', newValue); - }, - }, -}); +function updateItem(v) { + const i = props.modelValue.findIndex(x => x.id === v.id); + const newValue = [ + ...props.modelValue.slice(0, i), + v, + ...props.modelValue.slice(i + 1), + ]; + emit('update:modelValue', newValue); +} + +function removeItem(el) { + const i = props.modelValue.findIndex(x => x.id === el.id); + const newValue = [ + ...props.modelValue.slice(0, i), + ...props.modelValue.slice(i + 1), + ]; + emit('update:modelValue', newValue); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue index dd733403af..0842b4fd26 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.container.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -1,5 +1,5 @@ <template> -<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> +<div class="cpjygsrt"> <header> <div class="title"><slot name="header"></slot></div> <div class="buttons"> @@ -16,58 +16,40 @@ </button> </div> </header> - <p v-show="showBody" v-if="error != null" class="error">{{ i18n.t('_pages.script.typeError', { slot: error.arg + 1, expect: i18n.t(`script.types.${error.expect}`), actual: i18n.t(`script.types.${error.actual}`) }) }}</p> - <p v-show="showBody" v-if="warn != null" class="warn">{{ i18n.t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> <div v-show="showBody" class="body"> <slot></slot> </div> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ref } from 'vue'; import { i18n } from '@/i18n'; -export default defineComponent({ - props: { - expanded: { - type: Boolean, - default: true, - }, - removable: { - type: Boolean, - default: true, - }, - draggable: { - type: Boolean, - default: false, - }, - error: { - required: false, - default: null, - }, - warn: { - required: false, - default: null, - }, - }, - emits: ['toggle', 'remove'], - data() { - return { - showBody: this.expanded, - i18n, - }; - }, - methods: { - toggleContent(show: boolean) { - this.showBody = show; - this.$emit('toggle', show); - }, - remove() { - this.$emit('remove'); - }, - }, +const props = withDefaults(defineProps<{ + expanded?: boolean; + removable?: boolean; + draggable?: boolean; +}>(), { + expanded: true, + removable: true, }); + +const emit = defineEmits<{ + (ev: 'toggle', show: boolean): void; + (ev: 'remove'): void; +}>(); + +const showBody = ref(props.expanded); + +function toggleContent(show: boolean) { + showBody.value = show; + emit('toggle', show); +} + +function remove() { + emit('remove'); +} </script> <style lang="scss" scoped> @@ -128,20 +110,6 @@ export default defineComponent({ } } - > .warn { - color: #b19e49; - margin: 0; - padding: 16px 16px 0 16px; - font-size: 14px; - } - - > .error { - color: #f00; - margin: 0; - padding: 16px 16px 0 16px; - font-size: 14px; - } - > .body { ::v-deep(.juejbjww), ::v-deep(.eiipwacr) { &:not(.inline):first-child { diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index bcf30e23a7..bd54699dc4 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <div class="jqqmcavi"> <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 5a0f58c8df..27a4cd0595 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> + <MkSpacer :contentMax="700"> <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="page" :key="page.id" class="xcukqgmh"> <div class="main"> @@ -18,8 +18,8 @@ </div> <div class="actions"> <div class="like"> - <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" as-like primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" as-like @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> </div> <div class="other"> <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index 0427332ab2..4f67bda11f 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -1,23 +1,29 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div v-if="tab === 'featured'" class="rknalgpo"> + <MkSpacer :contentMax="700"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> - <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + <div class="_gaps"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> + </div> </MkPagination> </div> - <div v-else-if="tab === 'my'" class="rknalgpo my"> + <div v-else-if="tab === 'my'" class="_gaps"> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="myPagesPagination"> - <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + <div class="_gaps"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> + </div> </MkPagination> </div> - <div v-else-if="tab === 'liked'" class="rknalgpo"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> - <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/> + <div class="_gaps"> + <MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/> + </div> </MkPagination> </div> </MkSpacer> @@ -79,21 +85,3 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-note', }))); </script> - -<style lang="scss" scoped> -.rknalgpo { - &.my .ckltabjg:first-child { - margin-top: 16px; - } - - .ckltabjg:not(:last-child) { - margin-bottom: 8px; - } - - @media (min-width: 500px) { - .ckltabjg:not(:last-child) { - margin-bottom: 16px; - } - } -} -</style> diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue deleted file mode 100644 index 354f686e46..0000000000 --- a/packages/frontend/src/pages/preview.vue +++ /dev/null @@ -1,27 +0,0 @@ -<template> -<div class="graojtoi"> - <MkSample/> -</div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import MkSample from '@/components/MkSample.vue'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - -definePageMetadata(computed(() => ({ - title: i18n.ts.preview, - icon: 'ti ti-eye', -}))); -</script> - -<style lang="scss" scoped> -.graojtoi { - padding: var(--margin); -} -</style> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index c687b89eab..b1d41fe2c7 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="600" :margin-min="16"> + <MkSpacer :contentMax="600" :marginMin="16"> <div class="_gaps_m"> <FormSplit> <MkKeyValue> @@ -93,6 +93,3 @@ definePageMetadata({ icon: 'ti ti-adjustments', }); </script> - -<style lang="scss" scoped> -</style> diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue index 00e2ca5e03..513a2f8feb 100644 --- a/packages/frontend/src/pages/registry.value.vue +++ b/packages/frontend/src/pages/registry.value.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="600" :margin-min="16"> + <MkSpacer :contentMax="600" :marginMin="16"> <div class="_gaps_m"> <FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo> @@ -118,6 +118,3 @@ definePageMetadata({ icon: 'ti ti-adjustments', }); </script> - -<style lang="scss" scoped> -</style> diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue index 5a029cb0c7..6bfb9bce58 100644 --- a/packages/frontend/src/pages/registry.vue +++ b/packages/frontend/src/pages/registry.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="600" :margin-min="16"> + <MkSpacer :contentMax="600" :marginMin="16"> <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> <FormSection v-if="scopes"> @@ -68,6 +68,3 @@ definePageMetadata({ icon: 'ti ti-adjustments', }); </script> - -<style lang="scss" scoped> -</style> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 38c88cc650..9d57307314 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32"> + <MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32"> <div class="_gaps_m"> <MkInput v-model="password" type="password"> <template #prefix><i class="ti ti-lock"></i></template> @@ -53,7 +53,3 @@ definePageMetadata({ icon: 'ti ti-lock', }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index fe39c594ba..e85ab0917a 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template> - <MKSpacer v-if="!(typeof error === 'undefined')" :content-max="1200"> + <MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200"> <div :class="$style.root"> <img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> <p :class="$style.text"> @@ -10,17 +10,18 @@ </p> </div> </MKSpacer> - <MkSpacer v-else-if="tab === 'users'" :content-max="1200"> + <MkSpacer v-else-if="tab === 'users'" :contentMax="1200"> <div class="_gaps_s"> <div v-if="role">{{ role.description }}</div> <MkUserList :pagination="users" :extractor="(item) => item.user"/> </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'timeline'" :content-max="700"> + <MkSpacer v-else-if="tab === 'timeline'" :contentMax="700"> <MkTimeline ref="timeline" src="role" :role="props.role"/> </MkSpacer> </MkStickyContainer> </template> + <script lang="ts" setup> import { computed, watch } from 'vue'; import * as os from '@/os'; @@ -80,6 +81,7 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-badge', }))); </script> + <style lang="scss" module> .root { padding: 32px; diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index fb78546cb1..22eb00dad4 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -1,8 +1,8 @@ <template> -<MkSpacer :content-max="800"> +<MkSpacer :contentMax="800"> <div :class="$style.root"> <div :class="$style.editor" class="_panel"> - <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/> + <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :lineNumbers="false"/> <MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton> </div> diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 23a8978fd1..bd1389ffef 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -4,7 +4,7 @@ <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkRadios v-model="searchOrigin" @update:model-value="search()"> + <MkRadios v-model="searchOrigin" @update:modelValue="search()"> <option value="combined">{{ i18n.ts.all }}</option> <option value="local">{{ i18n.ts.local }}</option> <option value="remote">{{ i18n.ts.remote }}</option> diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 9f3d8da560..dcaf42e648 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="tab === 'note'" :content-max="800"> + <MkSpacer v-if="tab === 'note'" :contentMax="800"> <div v-if="notesSearchAvailable"> <XNote/> </div> @@ -11,7 +11,7 @@ </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'user'" :content-max="800"> + <MkSpacer v-else-if="tab === 'user'" :contentMax="800"> <XUser/> </MkSpacer> </MkStickyContainer> diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 1d836db5f5..6a798b5626 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -1,8 +1,8 @@ <template> <MkModal ref="dialogEl" - :prefer-type="'dialog'" - :z-priority="'low'" + :preferType="'dialog'" + :zPriority="'low'" @click="cancel" @close="cancel" @closed="emit('closed')" diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index 891934d706..aff7765ed5 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -51,7 +51,7 @@ </div> </MkFolder> - <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)"> + <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :modelValue="usePasswordLessLogin" @update:modelValue="v => updatePasswordLessLogin(v)"> <template #label>{{ i18n.ts.passwordLessLogin }}</template> <template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template> </MkSwitch> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index a58e74fe69..78479be973 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -15,13 +15,13 @@ <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; +import type * as Misskey from 'misskey-js'; import FormSuspense from '@/components/form/suspense.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; -import type * as Misskey from 'misskey-js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; const storedAccounts = ref<any>(null); diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 599d6329e2..fbb78200d4 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -9,11 +9,11 @@ </template> <template #default="{items}"> <div class="_gaps"> - <div v-for="token in items" :key="token.id" class="_panel bfomjevm"> - <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/> - <div class="body"> - <div class="name">{{ token.name }}</div> - <div class="description">{{ token.description }}</div> + <div v-for="token in items" :key="token.id" class="_panel" :class="$style.app"> + <img v-if="token.iconUrl" :class="$style.appIcon" :src="token.iconUrl" alt=""/> + <div :class="$style.appBody"> + <div :class="$style.appName">{{ token.name }}</div> + <div>{{ token.description }}</div> <MkKeyValue oneline> <template #key>{{ i18n.ts.installedDate }}</template> <template #value><MkTime :time="token.createdAt"/></template> @@ -28,7 +28,7 @@ <li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> </ul> </details> - <div class="actions"> + <div> <MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton> </div> </div> @@ -75,27 +75,27 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.bfomjevm { +<style lang="scss" module> +.app { display: flex; padding: 16px; +} - > .icon { - display: block; - flex-shrink: 0; - margin: 0 12px 0 0; - width: 50px; - height: 50px; - border-radius: 8px; - } +.appIcon { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 50px; + height: 50px; + border-radius: 8px; +} - > .body { - width: calc(100% - 62px); - position: relative; +.appBody { + width: calc(100% - 62px); + position: relative; +} - > .name { - font-weight: bold; - } - } +.appName { + font-weight: bold; } </style> diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index 456c3742c5..970d5689b4 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -2,7 +2,7 @@ <div class="_gaps_m"> <FormInfo warn>{{ i18n.ts.customCssWarn }}</FormInfo> - <MkTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;"> + <MkTextarea v-model="localCustomCss" manualSave tall class="_monospace" style="tab-size: 2;"> <template #label>CSS</template> </MkTextarea> </div> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 73c2b2e604..8d7b30dc6e 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -4,8 +4,8 @@ <template #label>{{ i18n.ts.usageAmount }}</template> <div class="_gaps_m"> - <div class="uawsfosz"> - <div class="meter"><div :style="meterStyle"></div></div> + <div> + <div :class="$style.meter"><div :class="$style.meterValue" :style="meterStyle"></div></div> </div> <FormSplit> <MkKeyValue> @@ -22,7 +22,7 @@ <FormSection> <template #label>{{ i18n.ts.statistics }}</template> - <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/> + <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspectRatio="6"/> </FormSection> <FormSection> @@ -39,10 +39,10 @@ <template #label>{{ i18n.ts.keepOriginalUploading }}</template> <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> </MkSwitch> - <MkSwitch v-model="alwaysMarkNsfw" @update:model-value="saveProfile()"> + <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> </MkSwitch> - <MkSwitch v-model="autoSensitive" @update:model-value="saveProfile()"> + <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()"> <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> </MkSwitch> @@ -139,22 +139,16 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> - -@use "sass:math"; - -.uawsfosz { - - > .meter { - $size: 12px; - background: rgba(0, 0, 0, 0.1); - border-radius: math.div($size, 2); - overflow: hidden; +<style lang="scss" module> +.meter { + height: 10px; + background: rgba(0, 0, 0, 0.1); + border-radius: 999px; + overflow: clip; +} - > div { - height: $size; - border-radius: math.div($size, 2); - } - } +.meterValue { + height: 100%; + border-radius: 999px; } </style> diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index b1e6f223b6..d015cec154 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -2,7 +2,7 @@ <div v-if="instance.enableEmail" class="_gaps_m"> <FormSection first> <template #label>{{ i18n.ts.emailAddress }}</template> - <MkInput v-model="emailAddress" type="email" manual-save> + <MkInput v-model="emailAddress" type="email" manualSave> <template #prefix><i class="ti ti-mail"></i></template> <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template> @@ -10,7 +10,7 @@ </FormSection> <FormSection> - <MkSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail"> + <MkSwitch :modelValue="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> {{ i18n.ts.receiveAnnouncementFromInstance }} </MkSwitch> </FormSection> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index ba0f3274fc..20b36f0fcb 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -24,6 +24,7 @@ <div class="_gaps_s"> <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> + <MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch> </div> </FormSection> @@ -56,7 +57,7 @@ <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> <option value="force">{{ i18n.ts._nsfw.force }}</option> </MkSelect> - <!-- + <MkRadios v-model="mediaListWithOneImageAppearance"> <template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template> <option value="expand">{{ i18n.ts.default }}</option> @@ -64,7 +65,6 @@ <option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option> <option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option> </MkRadios> - --> </div> </FormSection> @@ -145,12 +145,20 @@ </FormSection> <FormSection> - <MkSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</MkSwitch> - </FormSection> + <template #label>{{ i18n.ts.other }}</template> - <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink> - - <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> + <div class="_gaps"> + <MkFolder> + <template #label>{{ i18n.ts.additionalEmojiDictionary }}</template> + <div v-for="lang in emojiIndexLangs" class="_buttons"> + <MkButton @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ lang }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> + <MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </MkFolder> + <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink> + <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> + </div> + </FormSection> </div> </template> @@ -160,6 +168,8 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkRange from '@/components/MkRange.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; @@ -212,10 +222,10 @@ const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')) const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); -const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode')); const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); +const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -244,7 +254,6 @@ watch([ useSystemFont, enableInfiniteScroll, squareAvatars, - aiChanMode, showNoteActionsOnlyHover, showGapBetweenNotesInTimeline, instanceTicker, @@ -253,6 +262,34 @@ watch([ await reloadAsk(); }); +const emojiIndexLangs = ['en-US']; + +function downloadEmojiIndex(lang: string) { + async function main() { + const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; + function download() { + switch (lang) { + case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default); + default: throw new Error('unrecognized lang: ' + lang); + } + } + currentIndexes[lang] = await download(); + await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes); + } + + os.promiseDialog(main()); +} + +function removeEmojiIndex(lang: string) { + async function main() { + const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; + delete currentIndexes[lang]; + await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes); + } + + os.promiseDialog(main()); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 34a962ef4c..b4f056d8a6 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> + <MkSpacer :contentMax="900" :marginMin="20" :marginMax="32"> <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> <div class="body"> <div v-if="!narrow || currentPage?.route.name == null" class="nav"> diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue index 541992875e..102bc68523 100644 --- a/packages/frontend/src/pages/settings/migration.vue +++ b/packages/frontend/src/pages/settings/migration.vue @@ -3,7 +3,7 @@ <FormInfo warn> {{ i18n.ts.thisIsExperimentalFeature }} </FormInfo> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-plane-arrival"></i></template> <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template> <template #caption>{{ i18n.ts._accountMigration.moveFromSub }}</template> @@ -25,7 +25,7 @@ </div> </MkFolder> - <MkFolder :default-open="!!$i?.movedTo"> + <MkFolder :defaultOpen="!!$i?.movedTo"> <template #icon><i class="ti ti-plane-departure"></i></template> <template #label>{{ i18n.ts._accountMigration.moveTo }}</template> @@ -48,7 +48,7 @@ <FormInfo>{{ i18n.ts._accountMigration.postMigrationNote }}</FormInfo> <FormInfo warn>{{ i18n.ts._accountMigration.movedAndCannotBeUndone }}</FormInfo> <div>{{ i18n.ts._accountMigration.movedTo }}</div> - <MkUserInfo v-if="movedTo" :user="movedTo" class="_panel _shadow" /> + <MkUserInfo v-if="movedTo" :user="movedTo" class="_panel _shadow"/> </template> </div> </MkFolder> @@ -57,6 +57,8 @@ <script lang="ts" setup> import { ref } from 'vue'; +import { toString } from 'misskey-js/built/acct'; +import { UserDetailed } from 'misskey-js/built/entities'; import FormInfo from '@/components/MkInfo.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; @@ -66,8 +68,6 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { $i } from '@/account'; -import { toString } from 'misskey-js/built/acct'; -import { UserDetailed } from 'misskey-js/built/entities'; import { unisonReload } from '@/scripts/unison-reload'; const moveToAccount = ref(''); diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index b3b33b8026..8780bfbc1e 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -2,10 +2,10 @@ <div class="_gaps_m"> <FormSlot> <template #label>{{ i18n.ts.navbar }}</template> - <MkContainer :show-header="false"> + <MkContainer :showHeader="false"> <Sortable v-model="items" - item-key="id" + itemKey="id" :animation="150" :handle="'.' + $style.itemHandle" @start="e => e.item.classList.add('active')" diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 2cf2f6d7f6..2552db4198 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -12,7 +12,7 @@ <div class="_gaps_m"> <MkPushNotificationAllowButton ref="allowButton"/> - <MkSwitch :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage"> + <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> <template #caption> <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 776305d723..0b73780a8b 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -53,6 +53,17 @@ </MkSwitch> </div> </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts.developer }}</template> + + <div class="_gaps_m"> + <MkSwitch v-model="devMode"> + <template #label>{{ i18n.ts.devMode }}</template> + </MkSwitch> + </div> + </MkFolder> </div> </FormSection> @@ -80,6 +91,7 @@ import FormSection from '@/components/form/section.vue'; const reportError = computed(defaultStore.makeGetterSetter('reportError')); const enableCondensedLineForAcct = computed(defaultStore.makeGetterSetter('enableCondensedLineForAcct')); +const devMode = computed(defaultStore.makeGetterSetter('devMode')); function onChangeInjectFeaturedNote(v) { os.api('i/update', { diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 8b57dceefb..75fae014f8 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -8,7 +8,7 @@ <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 :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> <MkKeyValue> <template #key>{{ i18n.ts.author }}</template> @@ -94,7 +94,3 @@ definePageMetadata({ icon: 'ti ti-plug', }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 6613ce4c1d..e34901cd11 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -32,7 +32,7 @@ </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, useCssModule } from 'vue'; +import { computed, onMounted, onUnmounted } from 'vue'; import { v4 as uuid } from 'uuid'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; @@ -40,7 +40,7 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import { ColdDeviceStorage, defaultStore } from '@/store'; import { unisonReload } from '@/scripts/unison-reload'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { version, host } from '@/config'; @@ -48,8 +48,6 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { miLocalStorage } from '@/local-storage'; const { t, ts } = i18n; -useCssModule(); - const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'menu', 'visibility', @@ -125,7 +123,7 @@ type Profile = { }; }; -const connection = $i && stream.useChannel('main'); +const connection = $i && useStream().useChannel('main'); let profiles = $ref<Record<string, Profile> | null>(null); diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index a1af0ba80b..7fd4d6d34e 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -1,14 +1,14 @@ <template> <div class="_gaps_m"> - <MkSwitch v-model="isLocked" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch> - <MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch> + <MkSwitch v-model="isLocked" @update:modelValue="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch> + <MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:modelValue="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch> - <MkSwitch v-model="publicReactions" @update:model-value="save()"> + <MkSwitch v-model="publicReactions" @update:modelValue="save()"> {{ i18n.ts.makeReactionsPublic }} <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> </MkSwitch> - <MkSelect v-model="ffVisibility" @update:model-value="save()"> + <MkSelect v-model="ffVisibility" @update:modelValue="save()"> <template #label>{{ i18n.ts.ffVisibility }}</template> <option value="public">{{ i18n.ts._ffVisibility.public }}</option> <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> @@ -16,26 +16,26 @@ <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template> </MkSelect> - <MkSwitch v-model="hideOnlineStatus" @update:model-value="save()"> + <MkSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> {{ i18n.ts.hideOnlineStatus }} <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> </MkSwitch> - <MkSwitch v-model="noCrawle" @update:model-value="save()"> + <MkSwitch v-model="noCrawle" @update:modelValue="save()"> {{ i18n.ts.noCrawle }} <template #caption>{{ i18n.ts.noCrawleDescription }}</template> </MkSwitch> - <MkSwitch v-model="preventAiLearning" @update:model-value="save()"> + <MkSwitch v-model="preventAiLearning" @update:modelValue="save()"> {{ i18n.ts.preventAiLearning }}<span class="_beta">{{ i18n.ts.beta }}</span> <template #caption>{{ i18n.ts.preventAiLearningDescription }}</template> </MkSwitch> - <MkSwitch v-model="isExplorable" @update:model-value="save()"> + <MkSwitch v-model="isExplorable" @update:modelValue="save()"> {{ i18n.ts.makeExplorable }} <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> </MkSwitch> <FormSection> <div class="_gaps_m"> - <MkSwitch v-model="rememberNoteVisibility" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch> + <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch> <MkFolder v-if="!rememberNoteVisibility"> <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> @@ -56,7 +56,7 @@ </div> </FormSection> - <MkSwitch v-model="keepCw" @update:model-value="save()">{{ i18n.ts.keepCw }}</MkSwitch> + <MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch> </div> </template> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 6ffd682610..58217d0475 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -1,28 +1,28 @@ <template> <div class="_gaps_m"> - <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> - <div class="avatar"> - <MkAvatar class="avatar" :user="$i" @click="changeAvatar"/> - <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> + <div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <div :class="$style.avatarContainer"> + <MkAvatar :class="$style.avatar" :user="$i" @click="changeAvatar"/> + <MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> </div> - <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> + <MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> </div> - <MkInput v-model="profile.name" :max="30" manual-save> + <MkInput v-model="profile.name" :max="30" manualSave> <template #label>{{ i18n.ts._profile.name }}</template> </MkInput> - <MkTextarea v-model="profile.description" :max="500" tall manual-save> + <MkTextarea v-model="profile.description" :max="500" tall manualSave> <template #label>{{ i18n.ts._profile.description }}</template> <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> </MkTextarea> - <MkInput v-model="profile.location" manual-save> + <MkInput v-model="profile.location" manualSave> <template #label>{{ i18n.ts.location }}</template> <template #prefix><i class="ti ti-map-pin"></i></template> </MkInput> - <MkInput v-model="profile.birthday" type="date" manual-save> + <MkInput v-model="profile.birthday" type="date" manualSave> <template #label>{{ i18n.ts.birthday }}</template> <template #prefix><i class="ti ti-cake"></i></template> </MkInput> @@ -48,7 +48,7 @@ <Sortable v-model="fields" class="_gaps_s" - item-key="id" + itemKey="id" :animation="150" :handle="'.' + $style.dragItemHandle" @start="e => e.item.classList.add('active')" @@ -59,7 +59,7 @@ <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> <div :class="$style.dragItemForm"> - <FormSplit :min-width="200"> + <FormSplit :minWidth="200"> <MkInput v-model="element.name" small> <template #label>{{ i18n.ts._profile.metadataLabel }}</template> </MkInput> @@ -88,11 +88,11 @@ <MkSelect v-model="reactionAcceptance"> <template #label>{{ i18n.ts.reactionAcceptance }}</template> <option :value="null">{{ i18n.ts.all }}</option> - <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> + <option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option> + <option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option> + <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> </MkSelect> - - <MkSwitch v-model="profile.showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch> </div> </template> @@ -248,36 +248,35 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.llvierxe { +<style lang="scss" module> +.avatarAndBanner { position: relative; background-size: cover; background-position: center; border: solid 1px var(--divider); border-radius: 10px; overflow: clip; +} - > .avatar { - display: inline-block; - text-align: center; - padding: 16px; +.avatarContainer { + display: inline-block; + text-align: center; + padding: 16px; +} - > .avatar { - display: inline-block; - width: 72px; - height: 72px; - margin: 0 auto 16px auto; - } - } +.avatar { + display: inline-block; + width: 72px; + height: 72px; + margin: 0 auto 16px auto; +} - > .bannerEdit { - position: absolute; - top: 16px; - right: 16px; - } +.bannerEdit { + position: absolute; + top: 16px; + right: 16px; } -</style> -<style lang="scss" module> + .metadataRoot { container-type: inline-size; } diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue index ed913731d3..cb483e34b5 100644 --- a/packages/frontend/src/pages/settings/reaction.vue +++ b/packages/frontend/src/pages/settings/reaction.vue @@ -3,15 +3,15 @@ <FromSlot> <template #label>{{ i18n.ts.reactionSettingDescription }}</template> <div v-panel style="border-radius: 6px;"> - <Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true"> + <Sortable v-model="reactions" :class="$style.reactions" :itemKey="item => item" :animation="150" :delay="100" :delayOnTouchOnly="true"> <template #item="{element}"> - <button class="_button item" @click="remove(element, $event)"> + <button class="_button" :class="$style.reactionsItem" @click="remove(element, $event)"> <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/> <MkEmoji v-else :emoji="element" :normal="true"/> </button> </template> <template #footer> - <button class="_button add" @click="chooseEmoji"><i class="ti ti-plus"></i></button> + <button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji"><i class="ti ti-plus"></i></button> </template> </Sortable> </div> @@ -135,20 +135,20 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.zoaiodol { +<style lang="scss" module> +.reactions { padding: 12px; font-size: 1.1em; +} - > .item { - display: inline-block; - padding: 8px; - cursor: move; - } +.reactionsItem { + display: inline-block; + padding: 8px; + cursor: move; +} - > .add { - display: inline-block; - padding: 8px; - } +.reactionsAdd { + display: inline-block; + padding: 8px; } </style> diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue index ba510dced3..05753c9b60 100644 --- a/packages/frontend/src/pages/settings/roles.vue +++ b/packages/frontend/src/pages/settings/roles.vue @@ -3,7 +3,7 @@ <FormSection first> <template #label>{{ i18n.ts.rolesAssignedToMe }}</template> <div class="_gaps_s"> - <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :for-moderation="false"/> + <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/> </div> </FormSection> <FormSection> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index 0cc2df09c5..2da84763a3 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -9,7 +9,7 @@ <FormSection> <template #label>{{ i18n.ts.signinHistory }}</template> - <MkPagination :pagination="pagination" disable-auto-load> + <MkPagination :pagination="pagination" disableAutoLoad> <template #default="{items}"> <div> <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index aa9f528006..c1a333548d 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -4,7 +4,7 @@ <template #label>{{ i18n.ts.sound }}</template> <option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option> </MkSelect> - <MkRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`"> + <MkRange v-model="volume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> <template #label>{{ i18n.ts.volume }}</template> </MkRange> diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 724fe43478..c2bf3f8cd5 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -1,6 +1,6 @@ <template> <div class="_gaps_m"> - <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`"> + <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> <template #label>{{ i18n.ts.masterVolume }}</template> </MkRange> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 81ff873e9e..c73ff7c075 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -7,7 +7,7 @@ <option value="userList">User list timeline</option> </MkSelect> - <MkInput v-model="statusbar.name" manual-save> + <MkInput v-model="statusbar.name" manualSave> <template #label>{{ i18n.ts.label }}</template> </MkInput> @@ -25,13 +25,13 @@ </MkRadios> <template v-if="statusbar.type === 'rss'"> - <MkInput v-model="statusbar.props.url" manual-save type="url"> + <MkInput v-model="statusbar.props.url" manualSave type="url"> <template #label>URL</template> </MkInput> <MkSwitch v-model="statusbar.props.shuffle"> <template #label>{{ i18n.ts.shuffle }}</template> </MkSwitch> - <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save type="number"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> @@ -43,7 +43,7 @@ </MkSwitch> </template> <template v-else-if="statusbar.type === 'federation'"> - <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save type="number"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> @@ -62,7 +62,7 @@ <template #label>{{ i18n.ts.userList }}</template> <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> </MkSelect> - <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save type="number"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue index f5a090a63b..bfb69936e1 100644 --- a/packages/frontend/src/pages/settings/statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -3,7 +3,7 @@ <MkFolder v-for="x in statusbars" :key="x.id"> <template #label>{{ x.type ?? i18n.ts.notSet }}</template> <template #suffix>{{ x.name }}</template> - <XStatusbar :_id="x.id" :user-lists="userLists"/> + <XStatusbar :_id="x.id" :userLists="userLists"/> </MkFolder> <MkButton primary @click="add">{{ i18n.ts.add }}</MkButton> </div> diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index d1821a00d4..0255435112 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -10,13 +10,13 @@ </optgroup> </MkSelect> <template v-if="selectedTheme"> - <MkInput readonly :model-value="selectedTheme.author"> + <MkInput readonly :modelValue="selectedTheme.author"> <template #label>{{ i18n.ts.author }}</template> </MkInput> - <MkTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc"> + <MkTextarea v-if="selectedTheme.desc" readonly :modelValue="selectedTheme.desc"> <template #label>{{ i18n.ts._theme.description }}</template> </MkTextarea> - <MkTextarea readonly tall :model-value="selectedThemeCode"> + <MkTextarea readonly tall :modelValue="selectedThemeCode"> <template #label>{{ i18n.ts._theme.code }}</template> <template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template> </MkTextarea> diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 78e0710162..e0ac899230 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -1,22 +1,25 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> + <MkSpacer :contentMax="800"> <MkPostForm v-if="state === 'writing'" fixed :instant="true" - :initial-text="initialText" - :initial-visibility="visibility" - :initial-files="files" - :initial-local-only="localOnly" + :initialText="initialText" + :initialVisibility="visibility" + :initialFiles="files" + :initialLocalOnly="localOnly" :reply="reply" :renote="renote" - :initial-visible-users="visibleUsers" + :initialVisibleUsers="visibleUsers" class="_panel" @posted="state = 'posted'" /> - <MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton> + <div v-else-if="state === 'posted'" class="_buttonsCenter"> + <MkButton primary @click="close">{{ i18n.ts.close }}</MkButton> + <MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton> + </div> </MkSpacer> </MkStickyContainer> </template> @@ -148,10 +151,14 @@ function close(): void { // 閉じなければ100ms後タイムラインに window.setTimeout(() => { - mainRouter.push('/'); + location.href = '/'; }, 100); } +function goToMisskey(): void { + location.href = '/'; +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); @@ -161,9 +168,3 @@ definePageMetadata({ icon: 'ti ti-share', }); </script> - -<style lang="scss" scoped> -.close { - margin: 16px auto; -} -</style> diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue index 5459532310..61d7eb24fd 100644 --- a/packages/frontend/src/pages/signup-complete.vue +++ b/packages/frontend/src/pages/signup-complete.vue @@ -1,41 +1,80 @@ <template> <div> - {{ i18n.ts.processing }} + <MkAnimBg style="position: fixed; top: 0;"/> + <div :class="$style.formContainer"> + <form :class="$style.form" class="_panel" @submit.prevent="submit()"> + <div :class="$style.banner"> + <i class="ti ti-user-check"></i> + </div> + <div class="_gaps_m" style="padding: 32px;"> + <div>{{ i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }) }}</div> + <div> + <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;"> + {{ submitting ? i18n.ts.processing : i18n.ts.gotIt }}<MkEllipsis v-if="submitting"/> + </MkButton> + </div> + </div> + </form> + </div> </div> </template> <script lang="ts" setup> -import { onMounted } from 'vue'; -import * as os from '@/os'; +import { } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkAnimBg from '@/components/MkAnimBg.vue'; import { login } from '@/account'; import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; +import * as os from '@/os'; + +let submitting = $ref(false); const props = defineProps<{ code: string; }>(); -onMounted(async () => { - await os.alert({ - type: 'info', - text: i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }), - }); - const res = await os.apiWithDialog('signup-pending', { - code: props.code, - }); - login(res.i, '/'); -}); - -const headerActions = $computed(() => []); +function submit() { + if (submitting) return; + submitting = true; -const headerTabs = $computed(() => []); + os.api('signup-pending', { + code: props.code, + }).then(res => { + return login(res.i, '/'); + }).catch(() => { + submitting = false; -definePageMetadata({ - title: i18n.ts.signup, - icon: 'ti ti-user', -}); + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + }); +} </script> -<style lang="scss" scoped> +<style lang="scss" module> +.formContainer { + min-height: 100svh; + padding: 32px 32px 64px 32px; + box-sizing: border-box; +display: grid; +place-content: center; +} + +.form { + position: relative; + z-index: 10; + border-radius: var(--radius); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: clip; + max-width: 500px; +} +.banner { + padding: 16px; + text-align: center; + font-size: 26px; + background-color: var(--accentedBg); + color: var(--accent); +} </style> diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index 511052c424..104e738866 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -1,16 +1,28 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> - <MkNotes class="" :pagination="pagination"/> + <MkSpacer :contentMax="800"> + <MkNotes ref="notes" class="" :pagination="pagination"/> </MkSpacer> + <template v-if="$i" #footer> + <div :class="$style.footer"> + <MkSpacer :contentMax="800" :marginMin="16" :marginMax="16"> + <MkButton rounded primary :class="$style.button" @click="post()"><i class="ti ti-pencil"></i>{{ i18n.ts.postToHashtag }}</MkButton> + </MkSpacer> + </div> + </template> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, ref } from 'vue'; import MkNotes from '@/components/MkNotes.vue'; +import MkButton from '@/components/MkButton.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; const props = defineProps<{ tag: string; @@ -23,6 +35,16 @@ const pagination = { tag: props.tag, })), }; +const notes = ref<InstanceType<typeof MkNotes>>(); + +async function post() { + defaultStore.set('postFormHashtags', props.tag); + defaultStore.set('postFormWithHashtags', true); + await os.post(); + defaultStore.set('postFormHashtags', ''); + defaultStore.set('postFormWithHashtags', false); + notes.value?.pagingComponent?.reload(); +} const headerActions = $computed(() => []); @@ -33,3 +55,16 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-hash', }))); </script> + +<style lang="scss" module> +.footer { + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-top: solid 0.5px var(--divider); + display: flex; +} + +.button { + margin: 0 auto var(--margin) auto; +} +</style> diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 56fdfdf782..f942b5005b 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -1,9 +1,9 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <div class="cwepdizn _gaps_m"> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.backgroundColor }}</template> <div class="cwepdizn-colors"> <div class="row"> @@ -19,7 +19,7 @@ </div> </MkFolder> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.accentColor }}</template> <div class="cwepdizn-colors"> <div class="row"> @@ -30,7 +30,7 @@ </div> </MkFolder> - <MkFolder :default-open="true"> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.textColor }}</template> <div class="cwepdizn-colors"> <div class="row"> @@ -41,7 +41,7 @@ </div> </MkFolder> - <MkFolder :default-open="false"> + <MkFolder :defaultOpen="false"> <template #icon><i class="ti ti-code"></i></template> <template #label>{{ i18n.ts.editCode }}</template> @@ -53,7 +53,7 @@ </div> </MkFolder> - <MkFolder :default-open="false"> + <MkFolder :defaultOpen="false"> <template #label>{{ i18n.ts.addDescription }}</template> <div class="_gaps_m"> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 1bf4cdc99a..a441c6f728 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -1,12 +1,12 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template> - <MkSpacer :content-max="800"> + <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> + <MkSpacer :contentMax="800"> <div ref="rootEl" v-hotkey.global="keymap"> <XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div :class="$style.tl"> <MkTimeline ref="tlComponent" @@ -187,13 +187,13 @@ definePageMetadata(computed(() => ({ &:first-child { margin-top: calc(-0.675em - 8px - var(--margin)); } +} - > button { - display: block; - margin: var(--margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; - } +.newButton { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; } .postForm { diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 94718d1533..56e8737e1c 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> + <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div v-if="tab === 'overview'" class="_gaps_m"> <div class="aeakzknw"> @@ -88,7 +88,7 @@ </div> <div v-else-if="tab === 'moderation'" class="_gaps_m"> - <MkSwitch v-model="suspended" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> + <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> <div> <MkButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> @@ -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" :for-moderation="true"/> + <MkRolePreview :class="$style.role" :role="role" :forModeration="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> @@ -135,10 +135,10 @@ <MkFolder> <template #icon><i class="ti ti-cloud"></i></template> <template #label>{{ i18n.ts.files }}</template> - <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + <MkFileListForAdmin :pagination="filesPagination" viewMode="grid"/> </MkFolder> - <MkTextarea v-model="moderationNote" manual-save> + <MkTextarea v-model="moderationNote" manualSave> <template #label>Moderation note</template> </MkTextarea> </div> diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index acf7ea9b2c..f66670e1f6 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -1,19 +1,20 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <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"> - <MkTimeline - ref="tlEl" :key="listId" - class="tl" - src="list" - :list="listId" - :sound="true" - @queue="queueUpdated" - /> + <MkSpacer :contentMax="800"> + <div ref="rootEl"> + <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div :class="$style.tl"> + <MkTimeline + ref="tlEl" :key="listId" + src="list" + :list="listId" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> - </div> + </MkSpacer> </MkStickyContainer> </template> @@ -82,36 +83,29 @@ definePageMetadata(computed(() => list ? { } : null)); </script> -<style lang="scss" scoped> -.eqqrhokj { - padding: var(--margin); +<style lang="scss" module> +.new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + margin: calc(-0.675em - 8px) 0; - > .new { - position: sticky; - top: calc(var(--stickyTop, 0px) + 16px); - z-index: 1000; - width: 100%; - margin: calc(-0.675em - 8px - var(--margin)) 0 calc(-0.675em - 8px); - - > button { - display: block; - margin: var(--margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; - } + &:first-child { + margin-top: calc(-0.675em - 8px - var(--margin)); } +} - > .tl { - background: var(--bg); - border-radius: var(--radius); - overflow: clip; - } +.newButton { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; } -@container (min-width: 800px) { - .eqqrhokj { - max-width: 800px; - margin: 0 auto; - } +.tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; } </style> diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue index fac7593e9c..01ef1126cb 100644 --- a/packages/frontend/src/pages/user-tag.vue +++ b/packages/frontend/src/pages/user-tag.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader/></template> - <MkSpacer :content-max="1200"> + <MkSpacer :contentMax="1200"> <div class="_gaps_s"> <MkUserList :pagination="tagUsers"/> </div> diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue index 1b3a6e24b3..7d5993c265 100644 --- a/packages/frontend/src/pages/user/achievements.vue +++ b/packages/frontend/src/pages/user/achievements.vue @@ -1,6 +1,6 @@ <template> -<MkSpacer :content-max="1200"> - <MkAchievements :user="user" :with-locked="false" :with-description="$i != null && (props.user.id === $i.id)"/> +<MkSpacer :contentMax="1200"> + <MkAchievements :user="user" :withLocked="false" :withDescription="$i != null && (props.user.id === $i.id)"/> </MkSpacer> </template> diff --git a/packages/frontend/src/pages/user/activity.vue b/packages/frontend/src/pages/user/activity.vue index cd538ad61f..655371ac1d 100644 --- a/packages/frontend/src/pages/user/activity.vue +++ b/packages/frontend/src/pages/user/activity.vue @@ -1,5 +1,5 @@ <template> -<MkSpacer :content-max="700"> +<MkSpacer :contentMax="700"> <div class="_gaps"> <MkFoldableSection class="item"> <template #header><i class="ti ti-activity"></i> Heatmap</template> @@ -34,7 +34,3 @@ const props = defineProps<{ }>(); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue index 95f8cbc296..08b7b9a71f 100644 --- a/packages/frontend/src/pages/user/clips.vue +++ b/packages/frontend/src/pages/user/clips.vue @@ -1,10 +1,10 @@ <template> -<MkSpacer :content-max="700"> - <div class="pages-user-clips"> - <MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="list"> - <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _margin"> +<MkSpacer :contentMax="700"> + <div> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" :class="$style.item" class="_panel _margin"> <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> + <div v-if="item.description" :class="$style.description">{{ item.description }}</div> </MkA> </MkPagination> </div> @@ -29,19 +29,15 @@ const pagination = { }; </script> -<style lang="scss" scoped> -.pages-user-clips { - > .list { - > .item { - display: block; - padding: 16px; +<style lang="scss" module> +.item { + display: block; + padding: 16px; +} - > .description { - margin-top: 8px; - padding-top: 8px; - border-top: solid 0.5px var(--divider); - } - } - } +.description { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); } </style> diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue index d42acd838f..4e76ddfe79 100644 --- a/packages/frontend/src/pages/user/follow-list.vue +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -1,8 +1,8 @@ <template> <div> - <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> - <div class="users"> - <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> + <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination"> + <div :class="$style.users"> + <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :user="user"/> </div> </MkPagination> </div> @@ -36,12 +36,10 @@ const followersPagination = { }; </script> -<style lang="scss" scoped> -.mk-following-or-followers { - > .users { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - grid-gap: var(--margin); - } +<style lang="scss" module> +.users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); } </style> diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue index 20573e67e9..b330f78637 100644 --- a/packages/frontend/src/pages/user/followers.vue +++ b/packages/frontend/src/pages/user/followers.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="1000"> + <MkSpacer :contentMax="1000"> <Transition name="fade" mode="out-in"> <div v-if="user"> <XFollowList :user="user" type="followers"/> @@ -56,6 +56,3 @@ definePageMetadata(computed(() => user ? { avatar: user, } : null)); </script> - -<style lang="scss" scoped> -</style> diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue index 3825f138cf..9544cf76ca 100644 --- a/packages/frontend/src/pages/user/following.vue +++ b/packages/frontend/src/pages/user/following.vue @@ -1,7 +1,7 @@ <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="1000"> + <MkSpacer :contentMax="1000"> <Transition name="fade" mode="out-in"> <div v-if="user"> <XFollowList :user="user" type="following"/> @@ -56,6 +56,3 @@ definePageMetadata(computed(() => user ? { avatar: user, } : null)); </script> - -<style lang="scss" scoped> -</style> diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue index b80e83fb11..b4bbab16fd 100644 --- a/packages/frontend/src/pages/user/gallery.vue +++ b/packages/frontend/src/pages/user/gallery.vue @@ -1,7 +1,7 @@ <template> -<MkSpacer :content-max="700"> +<MkSpacer :contentMax="700"> <MkPagination v-slot="{items}" :pagination="pagination"> - <div class="jrnovfpt"> + <div :class="$style.root"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> </div> </MkPagination> @@ -28,8 +28,8 @@ const pagination = { }; </script> -<style lang="scss" scoped> -.jrnovfpt { +<style lang="scss" module> +.root { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-gap: 12px; diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 9c133346d5..2e69eb367b 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -1,5 +1,5 @@ <template> -<MkSpacer :content-max="narrow ? 800 : 1100"> +<MkSpacer :contentMax="narrow ? 800 : 1100"> <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> <div class="main _gaps"> <!-- TODO --> @@ -7,7 +7,7 @@ <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> <div class="profile _gaps"> - <MkAccountMoved v-if="user.movedTo" :moved-to="user.movedTo"/> + <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> <div :key="user.id" class="main _panel"> @@ -49,7 +49,7 @@ </span> </div> <div v-if="iAmModerator" class="moderationNote"> - <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manual-save> + <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave> <template #label>Moderation note</template> </MkTextarea> <div v-else> @@ -69,7 +69,7 @@ </div> <div class="description"> <MkOmit> - <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i"/> + <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" :i="$i"/> <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> </MkOmit> </div> @@ -123,7 +123,7 @@ <XPhotos :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/> </template> - <MkNotes v-if="!disableNotes" :class="$style.tl" :no-gap="true" :pagination="pagination"/> + <MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/> </div> </div> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index 2d9ee85bc4..64d36307e9 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -9,7 +9,7 @@ </template> <div style="padding: 8px;"> - <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/> + <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspectRatio="5"/> </div> </MkContainer> </template> diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index d8fc253910..91c580ce96 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -1,5 +1,5 @@ <template> -<MkSpacer :content-max="800" style="padding-top: 0"> +<MkSpacer :contentMax="800" style="padding-top: 0"> <MkStickyContainer> <template #header> <MkTab v-model="include" :class="$style.tab"> @@ -8,7 +8,7 @@ <option value="files">{{ i18n.ts.withFiles }}</option> </MkTab> </template> - <MkNotes :no-gap="true" :pagination="pagination" :class="$style.tl"/> + <MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/> </MkStickyContainer> </MkSpacer> </template> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 03a226cc09..6aba815e9d 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -2,20 +2,19 @@ <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <div> - <Transition name="fade" mode="out-in"> - <div v-if="user"> - <XHome v-if="tab === 'home'" :user="user"/> - <XTimeline v-else-if="tab === 'notes'" :user="user" /> - <XActivity v-else-if="tab === 'activity'" :user="user"/> - <XAchievements v-else-if="tab === 'achievements'" :user="user"/> - <XReactions v-else-if="tab === 'reactions'" :user="user"/> - <XClips v-else-if="tab === 'clips'" :user="user"/> - <XPages v-else-if="tab === 'pages'" :user="user"/> - <XGallery v-else-if="tab === 'gallery'" :user="user"/> - </div> - <MkError v-else-if="error" @retry="fetchUser()"/> - <MkLoading v-else/> - </Transition> + <div v-if="user"> + <XHome v-if="tab === 'home'" :user="user"/> + <XTimeline v-else-if="tab === 'notes'" :user="user"/> + <XActivity v-else-if="tab === 'activity'" :user="user"/> + <XAchievements v-else-if="tab === 'achievements'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XLists v-else-if="tab === 'lists'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> </div> </MkStickyContainer> </template> @@ -36,6 +35,7 @@ const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); const XClips = defineAsyncComponent(() => import('./clips.vue')); +const XLists = defineAsyncComponent(() => import('./lists.vue')); const XPages = defineAsyncComponent(() => import('./pages.vue')); const XGallery = defineAsyncComponent(() => import('./gallery.vue')); @@ -91,6 +91,10 @@ const headerTabs = $computed(() => user ? [{ title: i18n.ts.clips, icon: 'ti ti-paperclip', }, { + key: 'lists', + title: i18n.ts.lists, + icon: 'ti ti-list', +}, { key: 'pages', title: i18n.ts.pages, icon: 'ti ti-news', @@ -112,14 +116,3 @@ definePageMetadata(computed(() => user ? { }, } : null)); </script> - -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} -</style> diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue new file mode 100644 index 0000000000..78f03d2b38 --- /dev/null +++ b/packages/frontend/src/pages/user/lists.vue @@ -0,0 +1,51 @@ +<template> +<MkStickyContainer> + <MkSpacer :contentMax="700"> + <div> + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists"> + <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/list/${ list.id }`"> + <div>{{ list.name }}</div> + <MkAvatars :userIds="list.userIds"/> + </MkA> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import {} from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; +import MkStickyContainer from '@/components/global/MkStickyContainer.vue'; +import MkSpacer from '@/components/global/MkSpacer.vue'; +import MkAvatars from '@/components/MkAvatars.vue'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +const pagination = { + endpoint: 'users/lists/list' as const, + noPaging: true, + limit: 10, + params: { + userId: props.user.id, + }, +}; +</script> + +<style lang="scss" module> +.list { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + margin-bottom: 8px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue index 7ea1d75f43..a2975c7079 100644 --- a/packages/frontend/src/pages/user/pages.vue +++ b/packages/frontend/src/pages/user/pages.vue @@ -1,5 +1,5 @@ <template> -<MkSpacer :content-max="700"> +<MkSpacer :contentMax="700"> <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_margin"/> </MkPagination> @@ -24,7 +24,3 @@ const pagination = { })), }; </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue index 24129ec024..2281603394 100644 --- a/packages/frontend/src/pages/user/reactions.vue +++ b/packages/frontend/src/pages/user/reactions.vue @@ -1,11 +1,11 @@ <template> -<MkSpacer :content-max="700"> +<MkSpacer :contentMax="700"> <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> - <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _margin afdcfbfb"> - <div class="header"> - <MkAvatar class="avatar" :user="user"/> - <MkReactionIcon class="reaction" :reaction="item.type" :no-style="true"/> - <MkTime :time="item.createdAt" class="createdAt"/> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="_panel _margin"> + <div :class="$style.header"> + <MkAvatar :class="$style.avatar" :user="user"/> + <MkReactionIcon :class="$style.reaction" :reaction="item.type" :noStyle="true"/> + <MkTime :time="item.createdAt" :class="$style.createdAt"/> </div> <MkNote :key="item.id" :note="item.note"/> </div> @@ -33,29 +33,27 @@ const pagination = { }; </script> -<style lang="scss" scoped> -.afdcfbfb { - > .header { - display: flex; - align-items: center; - padding: 8px 16px; - margin-bottom: 8px; - border-bottom: solid 2px var(--divider); +<style lang="scss" module> +.header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); +} - > .avatar { - width: 24px; - height: 24px; - margin-right: 8px; - } +.avatar { + width: 24px; + height: 24px; + margin-right: 8px; +} - > .reaction { - width: 32px; - height: 32px; - } +.reaction { + width: 32px; + height: 32px; +} - > .createdAt { - margin-left: auto; - } - } +.createdAt { + margin-left: auto; } </style> diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 929152bd5a..f082b4b3c7 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -6,11 +6,11 @@ <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="🍮"/> + <MkEmoji :normal="true" :noStyle="true" emoji="👍"/> + <MkEmoji :normal="true" :noStyle="true" emoji="❤"/> + <MkEmoji :normal="true" :noStyle="true" emoji="😆"/> + <MkEmoji :normal="true" :noStyle="true" emoji="🎉"/> + <MkEmoji :normal="true" :noStyle="true" emoji="🍮"/> </div> <div class="contents"> <MkVisitorDashboard/> diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 7728d97a65..2081cb9c93 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -1,27 +1,32 @@ <template> -<form :class="$style.root" class="_panel" @submit.prevent="submit()"> - <div :class="$style.title"> - <div>Welcome to Misskey!</div> - <div :class="$style.version">v{{ version }}</div> +<div> + <MkAnimBg style="position: fixed; top: 0;"/> + <div :class="$style.formContainer"> + <form :class="$style.form" class="_panel" @submit.prevent="submit()"> + <div :class="$style.title"> + <div>Welcome to Misskey!</div> + <div :class="$style.version">v{{ version }}</div> + </div> + <div class="_gaps_m" style="padding: 32px;"> + <div>{{ i18n.ts.intro }}</div> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> + <template #label>{{ i18n.ts.username }}</template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-model="password" type="password" data-cy-admin-password> + <template #label>{{ i18n.ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <div> + <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;"> + {{ submitting ? i18n.ts.processing : i18n.ts.done }}<MkEllipsis v-if="submitting"/> + </MkButton> + </div> + </div> + </form> </div> - <div class="_gaps_m" style="padding: 32px;"> - <div>{{ i18n.ts.intro }}</div> - <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> - <template #label>{{ i18n.ts.username }}</template> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - </MkInput> - <MkInput v-model="password" type="password" data-cy-admin-password> - <template #label>{{ i18n.ts.password }}</template> - <template #prefix><i class="ti ti-lock"></i></template> - </MkInput> - <div> - <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;"> - {{ submitting ? i18n.ts.processing : i18n.ts.done }}<MkEllipsis v-if="submitting"/> - </MkButton> - </div> - </div> -</form> +</div> </template> <script lang="ts" setup> @@ -32,6 +37,7 @@ import { host, version } from '@/config'; import * as os from '@/os'; import { login } from '@/account'; import { i18n } from '@/i18n'; +import MkAnimBg from '@/components/MkAnimBg.vue'; let username = $ref(''); let password = $ref(''); @@ -58,12 +64,21 @@ function submit() { </script> <style lang="scss" module> -.root { +.formContainer { + min-height: 100svh; + padding: 32px 32px 64px 32px; + box-sizing: border-box; +display: grid; +place-content: center; +} + +.form { + position: relative; + z-index: 10; border-radius: var(--radius); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); - overflow: hidden; + overflow: clip; max-width: 500px; - margin: 32px auto; } .title { diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 6ec6e3f863..a93d103d4f 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -3,16 +3,16 @@ <div ref="scrollEl" :class="[$style.scrollbox, { [$style.scroll]: isScrolling }]"> <div v-for="note in notes" :key="note.id" :class="$style.note"> <div class="_panel" :class="$style.content"> - <div :class="$style.body"> + <div> <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i"/> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> <div v-if="note.files.length > 0" :class="$style.richcontent"> - <MkMediaList :media-list="note.files"/> + <MkMediaList :mediaList="note.files"/> </div> <div v-if="note.poll"> - <MkPoll :note="note" :read-only="true"/> + <MkPoll :note="note" :readOnly="true"/> </div> </div> <MkReactionsViewer ref="reactionsViewer" :note="note"/> diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index 2616a8a1d5..d97bd4be62 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -6,7 +6,7 @@ import { $i } from './account'; import { api } from './os'; import { get, set } from './scripts/idb-proxy'; import { defaultStore } from './store'; -import { stream } from './stream'; +import { useStream } from './stream'; import { deepClone } from './scripts/clone'; type StateDef = Record<string, { @@ -26,8 +26,6 @@ type PizzaxChannelMessage<T extends StateDef> = { userId?: string; }; -const connection = $i && stream.useChannel('main'); - export class Storage<T extends StateDef> { public readonly ready: Promise<void>; public readonly loaded: Promise<void>; @@ -105,8 +103,10 @@ export class Storage<T extends StateDef> { }); if ($i) { + const connection = useStream().useChannel('main'); + // streamingのuser storage updateイベントを監視して更新 - connection?.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { + connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; this.reactiveState[key].value = this.state[key] = value; diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index e46c1eeb77..6b11137d79 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -31,6 +31,10 @@ export const routes = [{ path: '/notes/:noteId', component: page(() => import('./pages/note.vue')), }, { + name: 'list', + path: '/list/:listId', + component: page(() => import('./pages/list.vue')), +}, { path: '/clips/:clipId', component: page(() => import('./pages/clip.vue')), }, { @@ -243,9 +247,6 @@ export const routes = [{ path: '/scratchpad', component: page(() => import('./pages/scratchpad.vue')), }, { - path: '/preview', - component: page(() => import('./pages/preview.vue')), -}, { path: '/auth/:token', component: page(() => import('./pages/auth.vue')), }, { diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts index 2e853b58b5..79661b7ce9 100644 --- a/packages/frontend/src/scripts/emojilist.ts +++ b/packages/frontend/src/scripts/emojilist.ts @@ -2,7 +2,6 @@ export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', ' export type UnicodeEmojiDef = { name: string; - keywords: string[]; char: string; category: typeof unicodeEmojiCategories[number]; } @@ -10,11 +9,16 @@ export type UnicodeEmojiDef = { // initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb import _emojilist from '../emojilist.json'; -export const emojilist = _emojilist as UnicodeEmojiDef[]; +export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({ + name: x[1] as string, + char: x[0] as string, + category: unicodeEmojiCategories[x[2]], +})); const _indexByChar = new Map<string, number>(); const _charGroupByCategory = new Map<string, string[]>(); -emojilist.forEach((emo, i) => { +for (let i = 0; i < emojilist.length; i++) { + const emo = emojilist[i]; _indexByChar.set(emo.char, i); if (_charGroupByCategory.has(emo.category)) { @@ -22,14 +26,14 @@ emojilist.forEach((emo, i) => { } else { _charGroupByCategory.set(emo.category, [emo.char]); } -}); +} export const emojiCharByCategory = _charGroupByCategory; -export function getEmojiName(char: string): string | undefined { +export function getEmojiName(char: string): string | null { const idx = _indexByChar.get(char); - if (typeof idx === 'undefined') { - return undefined; + if (idx == null) { + return null; } else { return emojilist[idx].name; } diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts index ed01b49054..060c8a1a11 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/scripts/get-drive-file-menu.ts @@ -73,7 +73,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) { action: () => rename(file), }, { text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, - icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off', + icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation', action: () => toggleSensitive(file), }, { text: i18n.ts.describeFile, diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index c8a6100253..960f26ca67 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -7,7 +7,7 @@ import { instance } from '@/instance'; import * as os from '@/os'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; -import { noteActions } from '@/store'; +import { defaultStore, noteActions } from '@/store'; import { miLocalStorage } from '@/local-storage'; import { getUserMenu } from '@/scripts/get-user-menu'; import { clipsCache } from '@/cache'; @@ -396,5 +396,15 @@ export function getNoteMenu(props: { }))]); } + if (defaultStore.state.devMode) { + menu = menu.concat([null, { + icon: 'ti ti-id', + text: i18n.ts.copyNoteId, + action: () => { + copyToClipboard(appearNote.id); + }, + }]); + } + return menu; } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 6ff9fb63f1..b055d26473 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -4,7 +4,7 @@ import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { host } from '@/config'; import * as os from '@/os'; -import { userActions } from '@/store'; +import { defaultStore, userActions } from '@/store'; import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; import { Router } from '@/nirax'; @@ -240,6 +240,16 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router }]); } + if (defaultStore.state.devMode) { + menu = menu.concat([null, { + icon: 'ti ti-id', + text: i18n.ts.copyUserId, + action: () => { + copyToClipboard(user.id); + }, + }]); + } + if ($i && meId === user.id) { menu = menu.concat([null, { icon: 'ti ti-pencil', diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts deleted file mode 100644 index 804c5c1124..0000000000 --- a/packages/frontend/src/scripts/hpml/block.ts +++ /dev/null @@ -1,109 +0,0 @@ -// blocks - -export type BlockBase = { - id: string; - type: string; -}; - -export type TextBlock = BlockBase & { - type: 'text'; - text: string; -}; - -export type SectionBlock = BlockBase & { - type: 'section'; - title: string; - children: (Block | VarBlock)[]; -}; - -export type ImageBlock = BlockBase & { - type: 'image'; - fileId: string | null; -}; - -export type ButtonBlock = BlockBase & { - type: 'button'; - text: any; - primary: boolean; - action: string; - content: string; - event: string; - message: string; - var: string; - fn: string; -}; - -export type IfBlock = BlockBase & { - type: 'if'; - var: string; - children: Block[]; -}; - -export type TextareaBlock = BlockBase & { - type: 'textarea'; - text: string; -}; - -export type PostBlock = BlockBase & { - type: 'post'; - text: string; - attachCanvasImage: boolean; - canvasId: string; -}; - -export type CanvasBlock = BlockBase & { - type: 'canvas'; - name: string; // canvas id - width: number; - height: number; -}; - -export type NoteBlock = BlockBase & { - type: 'note'; - detailed: boolean; - note: string | null; -}; - -export type Block = - TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock; - -// variable blocks - -export type VarBlockBase = BlockBase & { - name: string; -}; - -export type NumberInputVarBlock = VarBlockBase & { - type: 'numberInput'; - text: string; -}; - -export type TextInputVarBlock = VarBlockBase & { - type: 'textInput'; - text: string; -}; - -export type SwitchVarBlock = VarBlockBase & { - type: 'switch'; - text: string; -}; - -export type RadioButtonVarBlock = VarBlockBase & { - type: 'radioButton'; - title: string; - values: string[]; -}; - -export type CounterVarBlock = VarBlockBase & { - type: 'counter'; - text: string; - inc: number; -}; - -export type VarBlock = - NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock; - -const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter']; -export function isVarBlock(block: Block): block is VarBlock { - return varBlock.includes(block.type); -} diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts deleted file mode 100644 index 9adfba7f27..0000000000 --- a/packages/frontend/src/scripts/hpml/evaluator.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { ref, Ref, unref } from 'vue'; -import { collectPageVars } from '../collect-page-vars'; -import { initHpmlLib } from './lib'; -import { Expr, isLiteralValue, Variable } from './expr'; -import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; -import { version } from '@/config'; - -/** - * Hpml evaluator - */ -export class Hpml { - private variables: Variable[]; - private pageVars: PageVar[]; - private envVars: Record<keyof typeof envVarsDef, any>; - public pageVarUpdatedCallback?: values.VFn; - public canvases: Record<string, HTMLCanvasElement> = {}; - public vars: Ref<Record<string, any>> = ref({}); - public page: Record<string, any>; - - private opts: { - randomSeed: string; visitor?: any; url?: string; - }; - - constructor(page: Hpml['page'], opts: Hpml['opts']) { - this.page = page; - this.variables = this.page.variables; - this.pageVars = collectPageVars(this.page.content); - this.opts = opts; - - const date = new Date(); - - this.envVars = { - AI: 'kawaii', - VERSION: version, - URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '', - LOGIN: opts.visitor != null, - NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', - USERNAME: opts.visitor ? opts.visitor.username : '', - USERID: opts.visitor ? opts.visitor.id : '', - NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, - FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, - FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, - IS_CAT: opts.visitor ? opts.visitor.isCat : false, - SEED: opts.randomSeed ? opts.randomSeed : '', - YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, - AISCRIPT_DISABLED: true, - NULL: null, - }; - - this.eval(); - } - - public eval() { - try { - this.vars.value = this.evaluateVars(); - } catch (err) { - //this.onError(e); - } - } - - public interpolate(str: string) { - if (str == null) return null; - return str.replace(/{(.+?)}/g, match => { - const v = unref(this.vars)[match.slice(1, -1).trim()]; - return v == null ? 'NULL' : v.toString(); - }); - } - - public registerCanvas(id: string, canvas: any) { - this.canvases[id] = canvas; - } - - public updatePageVar(name: string, value: any) { - const pageVar = this.pageVars.find(v => v.name === name); - if (pageVar !== undefined) { - pageVar.value = value; - } else { - throw new HpmlError(`No such page var '${name}'`); - } - } - - public updateRandomSeed(seed: string) { - this.opts.randomSeed = seed; - this.envVars.SEED = seed; - } - - private _interpolateScope(str: string, scope: HpmlScope) { - return str.replace(/{(.+?)}/g, match => { - const v = scope.getState(match.slice(1, -1).trim()); - return v == null ? 'NULL' : v.toString(); - }); - } - - public evaluateVars(): Record<string, any> { - const values: Record<string, any> = {}; - - for (const [k, v] of Object.entries(this.envVars)) { - values[k] = v; - } - - for (const v of this.pageVars) { - values[v.name] = v.value; - } - - for (const v of this.variables) { - values[v.name] = this.evaluate(v, new HpmlScope([values])); - } - - return values; - } - - private evaluate(expr: Expr, scope: HpmlScope): any { - if (isLiteralValue(expr)) { - if (expr.type === null) { - return null; - } - - if (expr.type === 'number') { - return parseInt((expr.value as any), 10); - } - - if (expr.type === 'text' || expr.type === 'multiLineText') { - return this._interpolateScope(expr.value || '', scope); - } - - if (expr.type === 'textList') { - return this._interpolateScope(expr.value || '', scope).trim().split('\n'); - } - - if (expr.type === 'ref') { - return scope.getState(expr.value); - } - - // Define user function - if (expr.type === 'fn') { - return { - slots: expr.value.slots.map(x => x.name), - exec: (slotArg: Record<string, any>) => { - return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id)); - }, - } as Fn; - } - return; - } - - // Call user function - if (expr.type.startsWith('fn:')) { - const fnName = expr.type.split(':')[1]; - const fn = scope.getState(fnName); - const args = {} as Record<string, any>; - for (let i = 0; i < fn.slots.length; i++) { - const name = fn.slots[i]; - args[name] = this.evaluate(expr.args[i], scope); - } - return fn.exec(args); - } - - if (expr.args === undefined) return null; - - const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor); - - // Call function - const fnName = expr.type; - const fn = (funcs as any)[fnName]; - if (fn == null) { - throw new HpmlError(`No such function '${fnName}'`); - } else { - return fn(...expr.args.map(x => this.evaluate(x, scope))); - } - } -} diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts deleted file mode 100644 index 18c7c2a14b..0000000000 --- a/packages/frontend/src/scripts/hpml/expr.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { literalDefs, Type } from '.'; - -export type ExprBase = { - id: string; -}; - -// value - -export type EmptyValue = ExprBase & { - type: null; - value: null; -}; - -export type TextValue = ExprBase & { - type: 'text'; - value: string; -}; - -export type MultiLineTextValue = ExprBase & { - type: 'multiLineText'; - value: string; -}; - -export type TextListValue = ExprBase & { - type: 'textList'; - value: string; -}; - -export type NumberValue = ExprBase & { - type: 'number'; - value: number; -}; - -export type RefValue = ExprBase & { - type: 'ref'; - value: string; // value is variable name -}; - -export type AiScriptRefValue = ExprBase & { - type: 'aiScriptVar'; - value: string; // value is variable name -}; - -export type UserFnValue = ExprBase & { - type: 'fn'; - value: UserFnInnerValue; -}; -type UserFnInnerValue = { - slots: { - name: string; - type: Type; - }[]; - expression: Expr; -}; - -export type Value = - EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue; - -export function isLiteralValue(expr: Expr): expr is Value { - if (expr.type == null) return true; - if (literalDefs[expr.type]) return true; - return false; -} - -// call function - -export type CallFn = ExprBase & { // "fn:hoge" or string - type: string; - args: Expr[]; - value: null; -}; - -// variable -export type Variable = (Value | CallFn) & { - name: string; -}; - -// expression -export type Expr = Variable | Value | CallFn; diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts deleted file mode 100644 index 994f286b9f..0000000000 --- a/packages/frontend/src/scripts/hpml/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Hpml - */ - -import { Hpml } from './evaluator'; -import { funcDefs } from './lib'; - -export type Fn = { - slots: string[]; - exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>; -}; - -export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; - -export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { - text: { out: 'string', category: 'value', icon: 'ti ti-quote' }, - multiLineText: { out: 'string', category: 'value', icon: 'ti ti-align-left' }, - textList: { out: 'stringArray', category: 'value', icon: 'ti ti-list' }, - number: { out: 'number', category: 'value', icon: 'ti ti-list-numbers' }, - ref: { out: null, category: 'value', icon: 'ti ti-wand' }, - aiScriptVar: { out: null, category: 'value', icon: 'ti ti-wand' }, - fn: { out: 'function', category: 'value', icon: 'ti ti-math-function' }, -}; - -export const blockDefs = [ - ...Object.entries(literalDefs).map(([k, v]) => ({ - type: k, out: v.out, category: v.category, icon: v.icon, - })), - ...Object.entries(funcDefs).map(([k, v]) => ({ - type: k, out: v.out, category: v.category, icon: v.icon, - })), -]; - -export type PageVar = { name: string; value: any; type: Type; }; - -export const envVarsDef: Record<string, Type> = { - AI: 'string', - URL: 'string', - VERSION: 'string', - LOGIN: 'boolean', - NAME: 'string', - USERNAME: 'string', - USERID: 'string', - NOTES_COUNT: 'number', - FOLLOWERS_COUNT: 'number', - FOLLOWING_COUNT: 'number', - IS_CAT: 'boolean', - SEED: null, - YMD: 'string', - AISCRIPT_DISABLED: 'boolean', - NULL: null, -}; - -export class HpmlScope { - private layerdStates: Record<string, any>[]; - public name: string; - - constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { - this.layerdStates = layerdStates; - this.name = name ?? 'anonymous'; - } - - public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope { - const layer = [states, ...this.layerdStates]; - return new HpmlScope(layer, name); - } - - /** - * 指定した名前の変数の値を取得します - * @param name 変数名 - */ - public getState(name: string): any { - for (const later of this.layerdStates) { - const state = later[name]; - if (state !== undefined) { - return state; - } - } - - throw new HpmlError( - `No such variable '${name}' in scope '${this.name}'`, { - scope: this.layerdStates, - }); - } -} - -export class HpmlError extends Error { - public info?: any; - - constructor(message: string, info?: any) { - super(message); - - this.info = info; - - // Maintains proper stack trace for where our error was thrown (only available on V8) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, HpmlError); - } - } -} diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts deleted file mode 100644 index 88db82dd27..0000000000 --- a/packages/frontend/src/scripts/hpml/lib.ts +++ /dev/null @@ -1,245 +0,0 @@ -import seedrandom from 'seedrandom'; -import { Hpml } from './evaluator'; -import { Expr } from './expr'; -import { Fn, HpmlScope } from '.'; - -/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color -// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs -Chart.pluginService.register({ - beforeDraw: (chart, easing) => { - if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) { - const ctx = chart.chart.ctx; - ctx.save(); - ctx.fillStyle = chart.config.options.chartArea.backgroundColor; - ctx.fillRect(0, 0, chart.chart.width, chart.chart.height); - ctx.restore(); - } - } -}); -*/ - -export function initAiLib(hpml: Hpml) { - return { - 'MkPages:updated': values.FN_NATIVE(([callback]) => { - hpml.pageVarUpdatedCallback = (callback as values.VFn); - }), - 'MkPages:get_canvas': values.FN_NATIVE(([id]) => { - utils.assertString(id); - const canvas = hpml.canvases[id.value]; - const ctx = canvas.getContext('2d'); - return values.OBJ(new Map([ - ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })], - ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })], - ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })], - ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })], - ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })], - ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })], - ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })], - ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })], - ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })], - ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })], - ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })], - ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })], - ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })], - ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })], - ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })], - ['fill', values.FN_NATIVE(() => { ctx.fill(); })], - ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })], - ])); - }), - 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { - /* TODO - utils.assertString(id); - utils.assertObject(opts); - const canvas = hpml.canvases[id.value]; - const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); - Chart.defaults.color = '#555'; - const chart = new Chart(canvas, { - type: opts.value.get('type').value, - data: { - labels: opts.value.get('labels').value.map(x => x.value), - datasets: opts.value.get('datasets').value.map(x => ({ - label: x.value.has('label') ? x.value.get('label').value : '', - data: x.value.get('data').value.map(x => x.value), - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: x.value.has('color') ? x.value.get('color') : color, - backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(), - })) - }, - options: { - responsive: false, - devicePixelRatio: 1.5, - title: { - display: opts.value.has('title'), - text: opts.value.has('title') ? opts.value.get('title').value : '', - fontSize: 14, - }, - layout: { - padding: { - left: 32, - right: 32, - top: opts.value.has('title') ? 16 : 32, - bottom: 16 - } - }, - legend: { - display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true, - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - tooltips: { - enabled: false, - }, - chartArea: { - backgroundColor: '#fff' - }, - ...(opts.value.get('type').value === 'radar' ? { - scale: { - ticks: { - display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false, - min: opts.value.has('min') ? opts.value.get('min').value : undefined, - max: opts.value.has('max') ? opts.value.get('max').value : undefined, - maxTicksLimit: 8, - }, - pointLabels: { - fontSize: 12 - } - } - } : { - scales: { - yAxes: [{ - ticks: { - display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true, - min: opts.value.has('min') ? opts.value.get('min').value : undefined, - max: opts.value.has('max') ? opts.value.get('max').value : undefined, - } - }] - } - }) - } - }); - */ - }), - }; -} - -export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { - if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' }, - for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'ti ti-recycle' }, - not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'ti ti-flag' }, - or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'ti ti-flag' }, - and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'ti ti-flag' }, - add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' }, - subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' }, - multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' }, - divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-divide' }, - mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-divide' }, - round: { in: ['number'], out: 'number', category: 'operation', icon: 'ti ti-calculator' }, - eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'ti ti-equal' }, - notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'ti ti-equal-not' }, - gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-greater' }, - lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-lower' }, - gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-equal-greater' }, - ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-equal-lower' }, - strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' }, - strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' }, - strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, - strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, - join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, - stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'ti ti-arrows-right-left' }, - numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'ti ti-arrows-right-left' }, - splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'ti ti-arrows-right-left' }, - pick: { in: [null, 'number'], out: null, category: 'list', icon: 'ti ti-indent-increase' }, - listLen: { in: [null], out: 'number', category: 'list', icon: 'ti ti-indent-increase' }, - rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'ti ti-dice' }, - dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'ti ti-dice' }, - seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'ti ti-dice' }, - random: { in: ['number'], out: 'boolean', category: 'random', icon: 'ti ti-dice' }, - dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'ti ti-dice' }, - seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'ti ti-dice' }, - randomPick: { in: [0], out: 0, category: 'random', icon: 'ti ti-dice' }, - dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'ti ti-dice' }, - seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'ti ti-dice' }, - DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'ti ti-dice' }, // dailyRandomPickWithProbabilityMapping -}; - -export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { - const date = new Date(); - const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; - - // SHOULD be fine to ignore since it's intended + function shape isn't defined - // eslint-disable-next-line @typescript-eslint/ban-types - const funcs: Record<string, Function> = { - not: (a: boolean) => !a, - or: (a: boolean, b: boolean) => a || b, - and: (a: boolean, b: boolean) => a && b, - eq: (a: any, b: any) => a === b, - notEq: (a: any, b: any) => a !== b, - gt: (a: number, b: number) => a > b, - lt: (a: number, b: number) => a < b, - gtEq: (a: number, b: number) => a >= b, - ltEq: (a: number, b: number) => a <= b, - if: (bool: boolean, a: any, b: any) => bool ? a : b, - for: (times: number, fn: Fn) => { - const result: any[] = []; - for (let i = 0; i < times; i++) { - result.push(fn.exec({ - [fn.slots[0]]: i + 1, - })); - } - return result; - }, - add: (a: number, b: number) => a + b, - subtract: (a: number, b: number) => a - b, - multiply: (a: number, b: number) => a * b, - divide: (a: number, b: number) => a / b, - mod: (a: number, b: number) => a % b, - round: (a: number) => Math.round(a), - strLen: (a: string) => a.length, - strPick: (a: string, b: number) => a[b - 1], - strReplace: (a: string, b: string, c: string) => a.split(b).join(c), - strReverse: (a: string) => a.split('').reverse().join(''), - join: (texts: string[], separator: string) => texts.join(separator || ''), - stringToNumber: (a: string) => parseInt(a), - numberToString: (a: number) => a.toString(), - splitStrByLine: (a: string) => a.split('\n'), - pick: (list: any[], i: number) => list[i - 1], - listLen: (list: any[]) => list.length, - random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability, - rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)), - randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)], - dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability, - dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)), - dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)], - seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, - seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), - seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], - DRPWPM: (list: string[]) => { - const xs: any[] = []; - let totalFactor = 0; - for (const x of list) { - const parts = x.split(' '); - const factor = parseInt(parts.pop()!, 10); - const text = parts.join(' '); - totalFactor += factor; - xs.push({ factor, text }); - } - const r = seedrandom(`${day}:${expr.id}`)() * totalFactor; - let stackedFactor = 0; - for (const x of xs) { - if (r >= stackedFactor && r <= stackedFactor + x.factor) { - return x.text; - } else { - stackedFactor += x.factor; - } - } - return xs[0].text; - }, - }; - - return funcs; -} diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts deleted file mode 100644 index ea8133f297..0000000000 --- a/packages/frontend/src/scripts/hpml/type-checker.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { isLiteralValue } from './expr'; -import { funcDefs } from './lib'; -import { envVarsDef } from '.'; -import type { Type, PageVar } from '.'; -import type { Expr, Variable } from './expr'; - -type TypeError = { - arg: number; - expect: Type; - actual: Type; -}; - -/** - * Hpml type checker - */ -export class HpmlTypeChecker { - public variables: Variable[]; - public pageVars: PageVar[]; - - constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) { - this.variables = variables; - this.pageVars = pageVars; - } - - public typeCheck(v: Expr): TypeError | null { - if (isLiteralValue(v)) return null; - - const def = funcDefs[v.type || '']; - if (def == null) { - throw new Error('Unknown type: ' + v.type); - } - - const generic: Type[] = []; - - for (let i = 0; i < def.in.length; i++) { - const arg = def.in[i]; - const type = this.infer(v.args[i]); - if (type === null) continue; - - if (typeof arg === 'number') { - if (generic[arg] === undefined) { - generic[arg] = type; - } else if (type !== generic[arg]) { - return { - arg: i, - expect: generic[arg], - actual: type, - }; - } - } else if (type !== arg) { - return { - arg: i, - expect: arg, - actual: type, - }; - } - } - - return null; - } - - public getExpectedType(v: Expr, slot: number): Type { - const def = funcDefs[v.type ?? '']; - if (def == null) { - throw new Error('Unknown type: ' + v.type); - } - - const generic: Type[] = []; - - for (let i = 0; i < def.in.length; i++) { - const arg = def.in[i]; - const type = this.infer(v.args[i]); - if (type === null) continue; - - if (typeof arg === 'number') { - if (generic[arg] === undefined) { - generic[arg] = type; - } - } - } - - if (typeof def.in[slot] === 'number') { - return generic[def.in[slot]] ?? null; - } else { - return def.in[slot]; - } - } - - public infer(v: Expr): Type { - if (v.type === null) return null; - if (v.type === 'text') return 'string'; - if (v.type === 'multiLineText') return 'string'; - if (v.type === 'textList') return 'stringArray'; - if (v.type === 'number') return 'number'; - if (v.type === 'ref') { - const variable = this.variables.find(va => va.name === v.value); - if (variable) { - return this.infer(variable); - } - - const pageVar = this.pageVars.find(va => va.name === v.value); - if (pageVar) { - return pageVar.type; - } - - const envVar = envVarsDef[v.value ?? '']; - if (envVar !== undefined) { - return envVar; - } - - return null; - } - if (v.type === 'aiScriptVar') return null; - if (v.type === 'fn') return null; // todo - if (v.type.startsWith('fn:')) return null; // todo - - const generic: Type[] = []; - - const def = funcDefs[v.type]; - - for (let i = 0; i < def.in.length; i++) { - const arg = def.in[i]; - if (typeof arg === 'number') { - const type = this.infer(v.args[i]); - - if (generic[arg] === undefined) { - generic[arg] = type; - } else { - if (type !== generic[arg]) { - generic[arg] = null; - } - } - } - } - - if (typeof def.out === 'number') { - return generic[def.out]; - } else { - return def.out; - } - } - - public getVarByName(name: string): Variable { - const v = this.variables.find(x => x.name === name); - if (v !== undefined) { - return v; - } else { - throw new Error(`No such variable '${name}'`); - } - } - - public getVarsByType(type: Type): Variable[] { - if (type == null) return this.variables; - return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); - } - - public getEnvVarsByType(type: Type): string[] { - if (type == null) return Object.keys(envVarsDef); - return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); - } - - public getPageVarsByType(type: Type): string[] { - if (type == null) return this.pageVars.map(v => v.name); - return this.pageVars.filter(v => type === v.type).map(v => v.name); - } - - public isUsedName(name: string) { - if (this.variables.some(v => v.name === name)) { - return true; - } - - if (this.pageVars.some(v => v.name === name)) { - return true; - } - - if (envVarsDef[name]) { - return true; - } - - return false; - } -} diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts new file mode 100644 index 0000000000..ccce8b02bf --- /dev/null +++ b/packages/frontend/src/scripts/idle-render.ts @@ -0,0 +1,38 @@ +class IdlingRenderScheduler { + #renderers: Set<FrameRequestCallback>; + #rafId: number; + #ricId: number; + + constructor() { + this.#renderers = new Set(); + this.#rafId = 0; + this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline)); + } + + #schedule(deadline: IdleDeadline): void { + if (deadline.timeRemaining()) { + this.#rafId = requestAnimationFrame((time) => { + for (const renderer of this.#renderers) { + renderer(time); + } + }); + } + this.#ricId = requestIdleCallback((arg) => this.#schedule(arg)); + } + + add(renderer: FrameRequestCallback): void { + this.#renderers.add(renderer); + } + + delete(renderer: FrameRequestCallback): void { + this.#renderers.delete(renderer); + } + + dispose(): void { + this.#renderers.clear(); + cancelAnimationFrame(this.#rafId); + cancelIdleCallback(this.#ricId); + } +} + +export const defaultIdlingRenderScheduler = new IdlingRenderScheduler(); diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index fe9f0a2447..44a58d6c7d 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -1,7 +1,7 @@ import { ref } from 'vue'; import { DriveFile } from 'misskey-js/built/entities'; import * as os from '@/os'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import { uploadFile } from '@/scripts/upload'; @@ -51,7 +51,7 @@ export function chooseFileFromUrl(): Promise<DriveFile> { const marker = Math.random().toString(); // TODO: UUIDとか使う - const connection = stream.useChannel('main'); + const connection = useStream().useChannel('main'); connection.on('urlUploadFinished', urlResponse => { if (urlResponse.marker === marker) { res(urlResponse.file); diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts index 28284c7bcf..f2e8253565 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/scripts/theme.ts @@ -60,7 +60,7 @@ export function applyTheme(theme: Theme, persist = true) { document.documentElement.classList.remove('_themeChanging_'); }, 1000); - const colorSchema = theme.base === 'dark' ? 'dark' : 'light'; + const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; // Deep copy const _theme = deepClone(theme); @@ -83,11 +83,11 @@ export function applyTheme(theme: Theme, persist = true) { document.documentElement.style.setProperty(`--${k}`, v.toString()); } - document.documentElement.style.setProperty('color-schema', colorSchema); + document.documentElement.style.setProperty('color-scheme', colorScheme); if (persist) { miLocalStorage.setItem('theme', JSON.stringify(props)); - miLocalStorage.setItem('colorSchema', colorSchema); + miLocalStorage.setItem('colorScheme', colorScheme); } // 色計算など再度行えるようにクライアント全体に通知 diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts index 34e8b6b17c..b21978b186 100644 --- a/packages/frontend/src/scripts/time.ts +++ b/packages/frontend/src/scripts/time.ts @@ -5,15 +5,16 @@ const dateTimeIntervals = { }; export function dateUTC(time: number[]): Date { - const d = time.length === 2 ? Date.UTC(time[0], time[1]) - : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) - : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) - : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) - : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) - : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) - : null; + const d = + time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; - if (!d) throw 'wrong number of arguments'; + if (!d) throw new Error('wrong number of arguments'); return new Date(d); } diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index ffe33cccc0..22a01e066a 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -1,6 +1,6 @@ import { onUnmounted, Ref } from 'vue'; import * as misskey from 'misskey-js'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { $i } from '@/account'; export function useNoteCapture(props: { @@ -9,7 +9,7 @@ export function useNoteCapture(props: { isDeletedRef: Ref<boolean>; }) { const note = props.note; - const connection = $i ? stream : null; + const connection = $i ? useStream() : null; function onStreamNoteUpdated(noteData): void { const { type, id, body } = noteData; diff --git a/packages/frontend/src/scripts/worker-multi-dispatch.ts b/packages/frontend/src/scripts/worker-multi-dispatch.ts new file mode 100644 index 0000000000..1847a8ccff --- /dev/null +++ b/packages/frontend/src/scripts/worker-multi-dispatch.ts @@ -0,0 +1,75 @@ +function defaultUseWorkerNumber(prev: number, totalWorkers: number) { + return prev + 1; +} + +export class WorkerMultiDispatch<POST = any, RETURN = any> { + private symbol = Symbol('WorkerMultiDispatch'); + private workers: Worker[] = []; + private terminated = false; + private prevWorkerNumber = 0; + private getUseWorkerNumber = defaultUseWorkerNumber; + private finalizationRegistry: FinalizationRegistry<symbol>; + + constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) { + this.getUseWorkerNumber = getUseWorkerNumber; + for (let i = 0; i < concurrency; i++) { + this.workers.push(workerConstructor()); + } + + this.finalizationRegistry = new FinalizationRegistry(() => { + this.terminate(); + }); + this.finalizationRegistry.register(this, this.symbol); + + if (_DEV_) console.log('WorkerMultiDispatch: Created', this); + } + + public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) { + let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length); + workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length; + if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber); + this.prevWorkerNumber = workerNumber; + + // 不毛だがunionをoverloadに突っ込めない + // https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error + // https://github.com/microsoft/TypeScript/issues/14107 + if (Array.isArray(options)) { + this.workers[workerNumber].postMessage(message, options); + } else { + this.workers[workerNumber].postMessage(message, options); + } + return workerNumber; + } + + public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) { + this.workers.forEach(worker => { + worker.addEventListener('message', callback, options); + }); + } + + public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) { + this.workers.forEach(worker => { + worker.removeEventListener('message', callback, options); + }); + } + + public terminate() { + this.terminated = true; + if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this); + this.workers.forEach(worker => { + worker.terminate(); + }); + this.workers = []; + this.finalizationRegistry.unregister(this); + } + + public isTerminated() { + return this.terminated; + } + public getWorkers() { + return this.workers; + } + public getSymbol() { + return this.symbol; + } +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 245bcbefe1..6ba05c36ab 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -92,7 +92,7 @@ export const defaultStore = markRaw(new Storage('base', { }, reactionAcceptance: { where: 'account', - default: null as 'likeOnly' | 'likeOnlyForRemote' | null, + default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, }, mutedWords: { where: 'account', @@ -102,6 +102,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: [] as string[], }, + showTimelineReplies: { + where: 'account', + default: false, + }, menu: { where: 'deviceAccount', @@ -314,6 +318,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + devMode: { + where: 'device', + default: false, + }, mediaListWithOneImageAppearance: { where: 'device', default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', @@ -328,7 +336,11 @@ export const defaultStore = markRaw(new Storage('base', { }, enableCondensedLineForAcct: { where: 'device', - default: true, + default: false, + }, + additionalUnicodeEmojiIndexes: { + where: 'device', + default: {} as Record<string, Record<string, string[]>>, }, })); diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index dea3459b86..9cae58a26a 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -3,6 +3,14 @@ import { markRaw } from 'vue'; import { $i } from '@/account'; import { url } from '@/config'; -export const stream = markRaw(new Misskey.Stream(url, $i ? { - token: $i.token, -} : null)); +let stream: Misskey.Stream | null = null; + +export function useStream(): Misskey.Stream { + if (stream) return stream; + + stream = markRaw(new Misskey.Stream(url, $i ? { + token: $i.token, + } : null)); + + return stream; +} diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 20254d335e..b376e4c42d 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -22,11 +22,7 @@ } html { - touch-action: manipulation; background-color: var(--bg); - background-attachment: fixed; - background-size: cover; - background-position: center; color: var(--fg); accent-color: var(--accent); overflow: auto; @@ -38,7 +34,7 @@ html { tab-size: 2; &, * { - scrollbar-color: var(--scrollbarHandle) inherit; + scrollbar-color: var(--scrollbarHandle) transparent; scrollbar-width: thin; &::-webkit-scrollbar { @@ -87,6 +83,7 @@ html._themeChanging_ { } html, body { + touch-action: manipulation; margin: 0; padding: 0; scroll-behavior: smooth; @@ -483,3 +480,140 @@ hr { transform: scaleX(1.00) scaleY(1.00) ; } } + +// MFM ----------------------------- + +._mfm_blur_ { + filter: blur(6px); + transition: filter 0.3s; + + &:hover { + filter: blur(0px); + } +} + +.mfm-x2 { + --mfm-zoom-size: 200%; +} + +.mfm-x3 { + --mfm-zoom-size: 400%; +} + +.mfm-x4 { + --mfm-zoom-size: 600%; +} + +.mfm-x2, .mfm-x3, .mfm-x4 { + font-size: var(--mfm-zoom-size); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* only half effective */ + font-size: calc(var(--mfm-zoom-size) / 2 + 50%); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* disabled */ + font-size: 100%; + } + } +} + +@keyframes mfm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes mfm-spinX { + 0% { transform: perspective(128px) rotateX(0deg); } + 100% { transform: perspective(128px) rotateX(360deg); } +} + +@keyframes mfm-spinY { + 0% { transform: perspective(128px) rotateY(0deg); } + 100% { transform: perspective(128px) rotateY(360deg); } +} + +@keyframes mfm-jump { + 0% { transform: translateY(0); } + 25% { transform: translateY(-16px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} + +@keyframes mfm-bounce { + 0% { transform: translateY(0) scale(1, 1); } + 25% { transform: translateY(-16px) scale(1, 1); } + 50% { transform: translateY(0) scale(1, 1); } + 75% { transform: translateY(0) scale(1.5, 0.75); } + 100% { transform: translateY(0) scale(1, 1); } +} + +// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px) } + 5% { transform: translate(-3px, 1px) } + 10% { transform: translate(-7px, -1px) } + 15% { transform: translate(0px, -1px) } + 20% { transform: translate(-8px, 6px) } + 25% { transform: translate(-4px, -3px) } + 30% { transform: translate(-4px, -6px) } + 35% { transform: translate(-8px, -8px) } + 40% { transform: translate(4px, 6px) } + 45% { transform: translate(-3px, 1px) } + 50% { transform: translate(2px, -10px) } + 55% { transform: translate(-7px, 0px) } + 60% { transform: translate(-2px, 4px) } + 65% { transform: translate(3px, -8px) } + 70% { transform: translate(6px, 7px) } + 75% { transform: translate(-7px, -2px) } + 80% { transform: translate(-7px, -8px) } + 85% { transform: translate(9px, 3px) } + 90% { transform: translate(-3px, -2px) } + 95% { transform: translate(-10px, 2px) } + 100% { transform: translate(-2px, -6px) } +} + +// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg) } + 5% { transform: translate(0px, -1px) rotate(-10deg) } + 10% { transform: translate(1px, -3px) rotate(0deg) } + 15% { transform: translate(1px, 1px) rotate(11deg) } + 20% { transform: translate(-2px, 1px) rotate(1deg) } + 25% { transform: translate(-1px, -2px) rotate(-2deg) } + 30% { transform: translate(-1px, 2px) rotate(-3deg) } + 35% { transform: translate(2px, 1px) rotate(6deg) } + 40% { transform: translate(-2px, -3px) rotate(-9deg) } + 45% { transform: translate(0px, -1px) rotate(-12deg) } + 50% { transform: translate(1px, 2px) rotate(10deg) } + 55% { transform: translate(0px, -3px) rotate(8deg) } + 60% { transform: translate(1px, -1px) rotate(8deg) } + 65% { transform: translate(0px, -1px) rotate(-7deg) } + 70% { transform: translate(-1px, -3px) rotate(6deg) } + 75% { transform: translate(0px, -2px) rotate(4deg) } + 80% { transform: translate(-2px, -1px) rotate(3deg) } + 85% { transform: translate(1px, -3px) rotate(-10deg) } + 90% { transform: translate(1px, 0px) rotate(3deg) } + 95% { transform: translate(-2px, 0px) rotate(-3deg) } + 100% { transform: translate(2px, 1px) rotate(2deg) } +} + +@keyframes mfm-rubberBand { + from { transform: scale3d(1, 1, 1); } + 30% { transform: scale3d(1.25, 0.75, 1); } + 40% { transform: scale3d(0.75, 1.25, 1); } + 50% { transform: scale3d(1.15, 0.85, 1); } + 65% { transform: scale3d(0.95, 1.05, 1); } + 75% { transform: scale3d(1.05, 0.95, 1); } + to { transform: scale3d(1, 1, 1); } +} + +@keyframes mfm-rainbow { + 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } + 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } +} diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend/src/themes/_dark.json5 index a23d25e866..5ef6adb085 100644 --- a/packages/frontend/src/themes/_dark.json5 +++ b/packages/frontend/src/themes/_dark.json5 @@ -21,6 +21,7 @@ fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':lighten<3<@fg', fgOnAccent: '#fff', + fgOnWhite: '#333', divider: 'rgba(255, 255, 255, 0.1)', indicator: '@accent', panel: ':lighten<3<@bg', @@ -77,7 +78,7 @@ codeString: '#ffb675', codeNumber: '#cfff9e', codeBoolean: '#c59eff', - deckDivider: '#000', + deckBg: '#000', htmlThemeColor: '@bg', X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend/src/themes/_light.json5 index 713756221a..32f3c74909 100644 --- a/packages/frontend/src/themes/_light.json5 +++ b/packages/frontend/src/themes/_light.json5 @@ -21,6 +21,7 @@ fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':darken<3<@fg', fgOnAccent: '#fff', + fgOnWhite: '#333', divider: 'rgba(0, 0, 0, 0.1)', indicator: '@accent', panel: ':lighten<3<@bg', @@ -77,7 +78,7 @@ codeString: '#b98710', codeNumber: '#0fbbbb', codeBoolean: '#62b70c', - deckDivider: ':darken<3<@bg', + deckBg: ':darken<3<@bg', htmlThemeColor: '@bg', X2: ':darken<2<@panel', X3: 'rgba(0, 0, 0, 0.05)', diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend/src/themes/d-astro.json5 index c6a927ec3a..09a9ead1a2 100644 --- a/packages/frontend/src/themes/d-astro.json5 +++ b/packages/frontend/src/themes/d-astro.json5 @@ -53,6 +53,7 @@ panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', htmlThemeColor: '@bg', + fgOnWhite: '@accent', panelHighlight: ':lighten<3<@panel', listItemHoverBg: 'rgba(255, 255, 255, 0.03)', scrollbarHandle: 'rgba(255, 255, 255, 0.2)', diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5 index 33cf7aa817..62208d2378 100644 --- a/packages/frontend/src/themes/d-botanical.json5 +++ b/packages/frontend/src/themes/d-botanical.json5 @@ -11,6 +11,7 @@ bg: 'rgb(37, 38, 36)', fg: 'rgb(216, 212, 199)', fgHighlighted: '#fff', + fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: 'rgb(47, 47, 44)', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend/src/themes/d-cherry.json5 index a7e1ad1c80..f9638124c2 100644 --- a/packages/frontend/src/themes/d-cherry.json5 +++ b/packages/frontend/src/themes/d-cherry.json5 @@ -10,6 +10,7 @@ accent: 'rgb(255, 89, 117)', bg: 'rgb(28, 28, 37)', fg: 'rgb(236, 239, 244)', + fgOnWhite: '@accent', panel: 'rgb(35, 35, 47)', renote: '@accent', link: '@accent', diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5 index 63144e88ea..ae4f7d53f5 100644 --- a/packages/frontend/src/themes/d-dark.json5 +++ b/packages/frontend/src/themes/d-dark.json5 @@ -11,6 +11,7 @@ bg: '#232323', fg: 'rgb(199, 209, 216)', fgHighlighted: '#fff', + fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: '#2d2d2d', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5 index 0962a12411..f2c1f3eb86 100644 --- a/packages/frontend/src/themes/d-future.json5 +++ b/packages/frontend/src/themes/d-future.json5 @@ -12,6 +12,7 @@ fg: '#D5D5D6', fgHighlighted: '#fff', fgOnAccent: '#000', + fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.1)', panel: '#18181c', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5 index 9522f534a4..ca4e688fdb 100644 --- a/packages/frontend/src/themes/d-green-lime.json5 +++ b/packages/frontend/src/themes/d-green-lime.json5 @@ -12,6 +12,7 @@ fg: '#dee7e4', fgHighlighted: '#fff', fgOnAccent: '#192320', + fgOnWhite: '@accent', divider: '#e7fffb24', panel: '#192320', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5 index e542782c66..c2539816e2 100644 --- a/packages/frontend/src/themes/d-green-orange.json5 +++ b/packages/frontend/src/themes/d-green-orange.json5 @@ -12,6 +12,7 @@ fg: '#dee7e4', fgHighlighted: '#fff', fgOnAccent: '#192320', + fgOnWhite: '@accent', divider: '#e7fffb24', panel: '#192320', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend/src/themes/d-ice.json5 index 179b060dcf..b4abc0cacb 100644 --- a/packages/frontend/src/themes/d-ice.json5 +++ b/packages/frontend/src/themes/d-ice.json5 @@ -8,6 +8,7 @@ props: { accent: '#47BFE8', + fgOnWhite: '@accent', bg: '#212526', }, } diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend/src/themes/d-persimmon.json5 index e36265ff10..0ab6523dd7 100644 --- a/packages/frontend/src/themes/d-persimmon.json5 +++ b/packages/frontend/src/themes/d-persimmon.json5 @@ -11,6 +11,7 @@ bg: 'rgb(31, 33, 31)', fg: '#cdd8c7', fgHighlighted: '#fff', + fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: 'rgb(41, 43, 41)', infoFg: '@fg', diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend/src/themes/d-u0.json5 index b270f809ac..ed776746a8 100644 --- a/packages/frontend/src/themes/d-u0.json5 +++ b/packages/frontend/src/themes/d-u0.json5 @@ -55,6 +55,7 @@ codeNumber: '#cfff9e', codeString: '#ffb675', fgOnAccent: '#fff', + fgOnWhite: '@accent', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', navHoverFg: ':lighten<17<@fg', @@ -83,6 +84,6 @@ fgTransparentWeak: ':alpha<0.75<@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', - deckDivider: '#142022', + deckBg: '#142022', }, } diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend/src/themes/l-apricot.json5 index 1ed5525575..fe1f9f8927 100644 --- a/packages/frontend/src/themes/l-apricot.json5 +++ b/packages/frontend/src/themes/l-apricot.json5 @@ -10,6 +10,7 @@ accent: 'rgb(234, 154, 82)', bg: '#e6e5e2', fg: 'rgb(149, 143, 139)', + fgOnWhite: '@accent', panel: '#EEECE8', renote: '@accent', link: '@accent', diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend/src/themes/l-botanical.json5 index 2ea9a7d6c9..5c98927896 100644 --- a/packages/frontend/src/themes/l-botanical.json5 +++ b/packages/frontend/src/themes/l-botanical.json5 @@ -11,6 +11,7 @@ bg: 'e2deda', fg: '#3d3d3d', fgHighlighted: '#6bc9a0', + fgOnWhite: '@accent', divider: '#cfcfcf', panel: '@X14', panelHeaderBg: '@panel', diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend/src/themes/l-cherry.json5 index 5ad240241e..1189a28fe6 100644 --- a/packages/frontend/src/themes/l-cherry.json5 +++ b/packages/frontend/src/themes/l-cherry.json5 @@ -10,6 +10,7 @@ accent: 'rgb(219, 96, 114)', bg: 'rgb(254, 248, 249)', fg: 'rgb(152, 13, 26)', + fgOnWhite: '@accent', panel: 'rgb(255, 255, 255)', renote: '@accent', link: 'rgb(156, 187, 5)', diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend/src/themes/l-coffee.json5 index fbcd4fa9ef..b64cc73583 100644 --- a/packages/frontend/src/themes/l-coffee.json5 +++ b/packages/frontend/src/themes/l-coffee.json5 @@ -10,6 +10,7 @@ accent: '#9f8989', bg: '#f5f3f3', fg: '#7f6666', + fgOnWhite: '@accent', panel: '#fff', divider: 'rgba(87, 68, 68, 0.1)', renote: 'rgb(160, 172, 125)', diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend/src/themes/l-light.json5 index 248355c945..63c2e6d278 100644 --- a/packages/frontend/src/themes/l-light.json5 +++ b/packages/frontend/src/themes/l-light.json5 @@ -10,6 +10,7 @@ props: { bg: '#f9f9f9', fg: '#676767', + fgOnWhite: '@accent', divider: '#e8e8e8', header: ':alpha<0.7<@panel', navBg: '#fff', diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend/src/themes/l-rainy.json5 index 283dd74c6c..e7d1d5af00 100644 --- a/packages/frontend/src/themes/l-rainy.json5 +++ b/packages/frontend/src/themes/l-rainy.json5 @@ -10,6 +10,7 @@ accent: '#5db0da', bg: 'rgb(246 248 249)', fg: '#636b71', + fgOnWhite: '@accent', panel: '#fff', divider: 'rgb(230 233 234)', panelHeaderDivider: '@divider', diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend/src/themes/l-sushi.json5 index 5846927d65..e787d63734 100644 --- a/packages/frontend/src/themes/l-sushi.json5 +++ b/packages/frontend/src/themes/l-sushi.json5 @@ -10,6 +10,7 @@ accent: '#e36749', bg: '#f0eee9', fg: '#5f5f5f', + fgOnWhite: '@accent', renote: '@accent', link: '@accent', mention: '@accent', diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend/src/themes/l-u0.json5 index 03b114ba39..b77b15e3f0 100644 --- a/packages/frontend/src/themes/l-u0.json5 +++ b/packages/frontend/src/themes/l-u0.json5 @@ -55,6 +55,7 @@ codeNumber: '#cfff9e', codeString: '#ffb675', fgOnAccent: '#fff', + fgOnWhite: '@accent', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', navHoverFg: ':lighten<17<@fg', diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend/src/themes/l-vivid.json5 index b3c08f38ae..822ef948dd 100644 --- a/packages/frontend/src/themes/l-vivid.json5 +++ b/packages/frontend/src/themes/l-vivid.json5 @@ -52,6 +52,7 @@ driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', fgTransparent: ':alpha<0.5<@fg', + fgOnWhite: '@accent', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', htmlThemeColor: '@bg', diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 71a4285e9d..3b970eefbe 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -10,12 +10,20 @@ <XUpload v-if="uploads.length > 0"/> <TransitionGroup - tag="div" :class="[$style.notifications, $style[`notificationsPosition-${defaultStore.state.notificationPosition}`], $style[`notificationsStackAxis-${defaultStore.state.notificationStackAxis}`]]" - :move-class="defaultStore.state.animation ? $style.transition_notification_move : ''" - :enter-active-class="defaultStore.state.animation ? $style.transition_notification_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''" + tag="div" + :class="[$style.notifications, { + [$style.notificationsPosition_leftTop]: defaultStore.state.notificationPosition === 'leftTop', + [$style.notificationsPosition_leftBottom]: defaultStore.state.notificationPosition === 'leftBottom', + [$style.notificationsPosition_rightTop]: defaultStore.state.notificationPosition === 'rightTop', + [$style.notificationsPosition_rightBottom]: defaultStore.state.notificationPosition === 'rightBottom', + [$style.notificationsStackAxis_vertical]: defaultStore.state.notificationStackAxis === 'vertical', + [$style.notificationsStackAxis_horizontal]: defaultStore.state.notificationStackAxis === 'horizontal', + }]" + :moveClass="defaultStore.state.animation ? $style.transition_notification_move : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_notification_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''" > <div v-for="notification in notifications" :key="notification.id" :class="$style.notification"> <XNotification :notification="notification"/> @@ -40,7 +48,7 @@ import { popups, pendingApiRequestsCount } from '@/os'; import { uploads } from '@/scripts/upload'; import * as sound from '@/scripts/sound'; import { $i } from '@/account'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -55,7 +63,7 @@ function onNotification(notification) { if ($i.mutingNotificationTypes.includes(notification.type)) return; if (document.visibilityState === 'visible') { - stream.send('readNotification'); + useStream().send('readNotification'); notifications.unshift(notification); window.setTimeout(() => { @@ -71,7 +79,7 @@ function onNotification(notification) { } if ($i) { - const connection = stream.useChannel('main', null, 'UI'); + const connection = useStream().useChannel('main', null, 'UI'); connection.on('notification', onNotification); //#region Listen message from SW @@ -103,31 +111,31 @@ if ($i) { pointer-events: none; display: flex; - &.notificationsPosition-leftTop { + &.notificationsPosition_leftTop { top: var(--margin); left: 0; } - &.notificationsPosition-rightTop { + &.notificationsPosition_rightTop { top: var(--margin); right: 0; } - &.notificationsPosition-leftBottom { + &.notificationsPosition_leftBottom { bottom: calc(var(--minBottomSpacing) + var(--margin)); left: 0; } - &.notificationsPosition-rightBottom { + &.notificationsPosition_rightBottom { bottom: calc(var(--minBottomSpacing) + var(--margin)); right: 0; } - &.notificationsStackAxis-vertical { + &.notificationsStackAxis_vertical { width: 250px; - &.notificationsPosition-leftTop, - &.notificationsPosition-rightTop { + &.notificationsPosition_leftTop, + &.notificationsPosition_rightTop { flex-direction: column; .notification { @@ -137,8 +145,8 @@ if ($i) { } } - &.notificationsPosition-leftBottom, - &.notificationsPosition-rightBottom { + &.notificationsPosition_leftBottom, + &.notificationsPosition_rightBottom { flex-direction: column-reverse; .notification { @@ -149,11 +157,11 @@ if ($i) { } } - &.notificationsStackAxis-horizontal { + &.notificationsStackAxis_horizontal { width: 100%; - &.notificationsPosition-leftTop, - &.notificationsPosition-leftBottom { + &.notificationsPosition_leftTop, + &.notificationsPosition_leftBottom { flex-direction: row; .notification { @@ -163,8 +171,8 @@ if ($i) { } } - &.notificationsPosition-rightTop, - &.notificationsPosition-rightBottom { + &.notificationsPosition_rightTop, + &.notificationsPosition_rightBottom { flex-direction: row-reverse; .notification { diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 7a94a0c3ee..365486a0ae 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -1,43 +1,41 @@ <template> -<div class="kmwsukvl"> - <div class="body"> - <div class="top"> - <div class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> - <button v-click-anime class="item _button instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> - </button> - </div> - <div class="middle"> - <MkA v-click-anime class="item index" active-class="active" to="/" exact> - <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> - </MkA> - <template v-for="item in menu"> - <div v-if="item === '-'" class="divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> - <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span> - <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> - </MkA> - <button v-click-anime class="item _button" @click="more"> - <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span> - </button> - <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> - </MkA> - </div> - <div class="bottom"> - <button class="item _button post" data-cy-open-post-form @click="os.post"> - <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span> - </button> - <button v-click-anime class="item _button account" @click="openAccountMenu"> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text _nowrap" :user="$i"/> - </button> - </div> +<div :class="$style.root"> + <div :class="$style.top"> + <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> + <button class="_button" :class="$style.instance" @click="openInstanceMenu"> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> + </button> + </div> + <div :class="$style.middle"> + <MkA :class="$style.item" :activeClass="$style.active" to="/" exact> + <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" :class="$style.divider"></div> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> + <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span> + </component> + </template> + <div :class="$style.divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" :class="$style.item" :activeClass="$style.active" to="/admin"> + <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> + </MkA> + <button :class="$style.item" class="_button" @click="more"> + <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span> + </button> + <MkA :class="$style.item" :activeClass="$style.active" to="/settings"> + <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> + </MkA> + </div> + <div :class="$style.bottom"> + <button class="_button" :class="$style.post" data-cy-open-post-form @click="os.post"> + <i :class="$style.postIcon" class="ti ti-pencil ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span> + </button> + <button class="_button" :class="$style.account" @click="openAccountMenu"> + <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct :class="$style.acct" class="_nowrap" :user="$i"/> + </button> </div> </div> </template> @@ -73,192 +71,186 @@ function more() { } </script> -<style lang="scss" scoped> -.kmwsukvl { - > .body { - display: flex; - flex-direction: column; - - > .top { - position: sticky; - top: 0; - z-index: 1; - padding: 20px 0; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); +<style lang="scss" module> +.root { + display: flex; + flex-direction: column; +} - > .banner { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-size: cover; - background-position: center center; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); - } +.top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); +} - > .instance { - position: relative; - display: block; - text-align: center; - width: 100%; +.banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); +} - > .icon { - display: inline-block; - width: 38px; - aspect-ratio: 1; - } - } - } +.instance { + position: relative; + display: block; + text-align: center; + width: 100%; +} - > .bottom { - position: sticky; - bottom: 0; - padding: 20px 0; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); +.instanceIcon { + display: inline-block; + width: 38px; + aspect-ratio: 1; +} - > .post { - position: relative; - display: block; - width: 100%; - height: 40px; - color: var(--fgOnAccent); - font-weight: bold; - text-align: left; +.bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); +} - &:before { - content: ""; - display: block; - width: calc(100% - 38px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - } +.post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; - &:hover, &.active { - &:before { - background: var(--accentLighten); - } - } + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } - > .icon { - position: relative; - margin-left: 30px; - margin-right: 8px; - width: 32px; - } + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } +} - > .text { - position: relative; - } - } +.postIcon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; +} - > .account { - position: relative; - display: flex; - align-items: center; - padding-left: 30px; - width: 100%; - text-align: left; - box-sizing: border-box; - margin-top: 16px; +.account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; +} - > .avatar { - display: block; - flex-shrink: 0; - position: relative; - width: 32px; - aspect-ratio: 1; - margin-right: 8px; - } +.avatar { + display: block; + flex-shrink: 0; + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; +} - > .text { - display: block; - flex-shrink: 1; - padding-right: 8px; - } - } - } +.acct { + display: block; + flex-shrink: 1; + padding-right: 8px; +} - > .middle { - flex: 1; +.middle { + flex: 1; +} - > .divider { - margin: 16px 16px; - border-top: solid 0.5px var(--divider); - } +.divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); +} - > .item { - position: relative; - display: block; - padding-left: 24px; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--navFg); +.item { + position: relative; + display: block; + padding-left: 24px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); - > .icon { - position: relative; - width: 32px; - margin-right: 8px; - } + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } - > .indicator { - position: absolute; - top: 0; - left: 20px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } + &.active { + color: var(--navActive); + } - > .text { - position: relative; - font-size: 0.9em; - } + &:hover, &.active { + &:before { + content: ""; + display: block; + width: calc(100% - 24px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } +} - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } +.itemIcon { + position: relative; + width: 32px; + margin-right: 8px; +} - &.active { - color: var(--navActive); - } +.itemIndicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; +} - &:hover, &.active { - &:before { - content: ""; - display: block; - width: calc(100% - 24px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--accentedBg); - } - } - } - } - } +.itemText { + position: relative; + font-size: 0.9em; } </style> diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 3b4b161422..a184f1d2f0 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -1,51 +1,50 @@ <template> -<div class="mvcprjjd" :class="{ iconOnly }"> - <div class="body"> - <div class="top"> - <div class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> - <button v-click-anime v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> +<div :class="[$style.root, { [$style.iconOnly]: iconOnly }]"> + <div :class="$style.body"> + <div :class="$style.top"> + <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> + <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> </button> </div> - <div class="middle"> - <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact> - <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> + <div :class="$style.middle"> + <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> + <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> <template v-for="item in menu"> - <div v-if="item === '-'" class="divider"></div> + <div v-if="item === '-'" :class="$style.divider"></div> <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" - v-click-anime v-tooltip.noDelay.right="navbarItemDef[item].title" - class="item _button" - :class="[item, { active: navbarItemDef[item].active }]" - active-class="active" + class="_button" + :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" + :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" > - <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span> - <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> + <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span> </component> </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin"> - <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> + <div :class="$style.divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin"> + <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> </MkA> - <button v-click-anime class="item _button" @click="more"> - <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + <button class="_button" :class="$style.item" @click="more"> + <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span> </button> - <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings"> - <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> + <MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings"> + <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> </MkA> </div> - <div class="bottom"> - <button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post"> - <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span> + <div :class="$style.bottom"> + <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="os.post"> + <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> - <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu"> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text _nowrap" :user="$i"/> + <button v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> + <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> </button> </div> </div> @@ -99,374 +98,376 @@ function more(ev: MouseEvent) { } </script> -<style lang="scss" scoped> -.mvcprjjd { - $nav-width: 250px; - $nav-icon-only-width: 80px; +<style lang="scss" module> +.root { + --nav-width: 250px; + --nav-icon-only-width: 72px; - flex: 0 0 $nav-width; - width: $nav-width; + flex: 0 0 var(--nav-width); + width: var(--nav-width); box-sizing: border-box; +} - > .body { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: $nav-icon-only-width; - height: 100dvh; - box-sizing: border-box; - overflow: auto; - overflow-x: clip; - background: var(--navBg); - contain: strict; - display: flex; - flex-direction: column; - } - - &:not(.iconOnly) { - > .body { - width: $nav-width; - - > .top { - position: sticky; - top: 0; - z-index: 1; - padding: 20px 0; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - - > .banner { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-size: cover; - background-position: center center; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); - } +.body { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: var(--nav-icon-only-width); + height: 100dvh; + box-sizing: border-box; + overflow: auto; + overflow-x: clip; + overscroll-behavior: contain; + background: var(--navBg); + contain: strict; + display: flex; + flex-direction: column; +} - > .instance { - position: relative; - display: block; - text-align: center; - width: 100%; +.root:not(.iconOnly) { + .body { + width: var(--nav-width); + } - > .icon { - display: inline-block; - width: 38px; - aspect-ratio: 1; - } - } - } + .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + } - > .bottom { - position: sticky; - bottom: 0; - padding: 20px 0; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); + .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } - > .post { - position: relative; - display: block; - width: 100%; - height: 40px; - color: var(--fgOnAccent); - font-weight: bold; - text-align: left; + .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + } - &:before { - content: ""; - display: block; - width: calc(100% - 38px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - } + .instanceIcon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } - &:hover, &.active { - &:before { - background: var(--accentLighten); - } - } + .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + } - > .icon { - position: relative; - margin-left: 30px; - margin-right: 8px; - width: 32px; - } + .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; - > .text { - position: relative; - } - } + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } - > .account { - position: relative; - display: flex; - align-items: center; - padding-left: 30px; - width: 100%; - text-align: left; - box-sizing: border-box; - margin-top: 16px; + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + } - > .avatar { - display: block; - flex-shrink: 0; - position: relative; - width: 32px; - aspect-ratio: 1; - margin-right: 8px; - } + .postIcon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } - > .text { - display: block; - flex-shrink: 1; - padding-right: 8px; - } - } - } + .postText { + position: relative; + } - > .middle { - flex: 1; + .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + } - > .divider { - margin: 16px 16px; - border-top: solid 0.5px var(--divider); - } + .avatar { + display: block; + flex-shrink: 0; + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } - > .item { - position: relative; - display: block; - padding-left: 30px; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--navFg); + .acct { + display: block; + flex-shrink: 1; + padding-right: 8px; + } - > .icon { - position: relative; - width: 32px; - margin-right: 8px; - } + .middle { + flex: 1; + } - > .indicator { - position: absolute; - top: 0; - left: 20px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } + .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } - > .text { - position: relative; - font-size: 0.9em; - } + .item { + position: relative; + display: block; + padding-left: 30px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } - &.active { - color: var(--navActive); - } + &.active { + color: var(--navActive); + } - &:hover, &.active { - color: var(--accent); + &:hover, &.active { + color: var(--accent); - &:before { - content: ""; - display: block; - width: calc(100% - 34px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--accentedBg); - } - } - } + &:before { + content: ""; + display: block; + width: calc(100% - 34px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); } } } - &.iconOnly { - flex: 0 0 $nav-icon-only-width; - width: $nav-icon-only-width; + .itemIcon { + position: relative; + width: 32px; + margin-right: 8px; + } - > .body { - width: $nav-icon-only-width; + .itemIndicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } - > .top { - position: sticky; - top: 0; - z-index: 1; - padding: 20px 0; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); + .itemText { + position: relative; + font-size: 0.9em; + } +} - > .instance { - display: block; - text-align: center; - width: 100%; +.root.iconOnly { + flex: 0 0 var(--nav-icon-only-width); + width: var(--nav-icon-only-width); - > .icon { - display: inline-block; - width: 30px; - aspect-ratio: 1; - } - } - } + .body { + width: var(--nav-icon-only-width); + } - > .bottom { - position: sticky; - bottom: 0; - padding: 20px 0; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); + .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + } - > .post { - display: block; - position: relative; - width: 100%; - height: 52px; - margin-bottom: 16px; - text-align: center; + .instance { + display: block; + text-align: center; + width: 100%; + } - &:before { - content: ""; - display: block; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - width: 52px; - aspect-ratio: 1/1; - border-radius: 100%; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - } + .instanceIcon { + display: inline-block; + width: 30px; + aspect-ratio: 1; + } - &:hover, &.active { - &:before { - background: var(--accentLighten); - } - } + .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + } - > .icon { - position: relative; - color: var(--fgOnAccent); - } + .post { + display: block; + position: relative; + width: 100%; + height: 52px; + margin-bottom: 16px; + text-align: center; - > .text { - display: none; - } - } + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 52px; + aspect-ratio: 1/1; + border-radius: 100%; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } - > .account { - display: block; - text-align: center; - width: 100%; + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + } - > .avatar { - display: inline-block; - width: 38px; - aspect-ratio: 1; - } + .postIcon { + position: relative; + color: var(--fgOnAccent); + } - > .text { - display: none; - } - } - } + .postText { + display: none; + } - > .middle { - flex: 1; + .account { + display: block; + text-align: center; + width: 100%; + } - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - border-top: solid 0.5px var(--divider); - } + .avatar { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } - > .item { - display: block; - position: relative; - padding: 18px 0; - width: 100%; - text-align: center; + .acct { + display: none; + } - > .icon { - display: block; - margin: 0 auto; - opacity: 0.7; - } + .middle { + flex: 1; + } - > .text { - display: none; - } + .divider { + margin: 8px auto; + width: calc(100% - 32px); + border-top: solid 0.5px var(--divider); + } - > .indicator { - position: absolute; - top: 6px; - left: 24px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } + .item { + display: block; + position: relative; + padding: 18px 0; + width: 100%; + text-align: center; - &:hover, &.active { - text-decoration: none; - color: var(--accent); + &:hover, &.active { + text-decoration: none; + color: var(--accent); - &:before { - content: ""; - display: block; - height: 100%; - aspect-ratio: 1; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--accentedBg); - } + &:before { + content: ""; + display: block; + height: 100%; + aspect-ratio: 1; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } - > .icon, > .text { - opacity: 1; - } - } - } + > .icon, + > .text { + opacity: 1; } } } + + .itemIcon { + display: block; + margin: 0 auto; + opacity: 0.7; + } + + .itemText { + display: none; + } + + .itemIndicator { + position: absolute; + top: 6px; + left: 24px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } } </style> diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index fe95460ba4..6f2e4bc9a7 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -1,14 +1,20 @@ <template> -<span v-if="!fetching" class="nmidsaqw"> +<span v-if="!fetching" :class="$style.root"> <template v-if="display === 'marquee'"> - <Transition name="change" mode="default"> + <Transition + :enterActiveClass="$style.transition_change_enterActive" + :leaveActiveClass="$style.transition_change_leaveActive" + :enterFromClass="$style.transition_change_enterFrom" + :leaveToClass="$style.transition_change_leaveTo" + mode="default" + > <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> - <span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }"> - <img class="icon" :src="getInstanceIcon(instance)" alt=""/> - <MkA :to="`/instance-info/${instance.host}`" class="host _monospace"> + <span v-for="instance in instances" :key="instance.id" :class="[$style.item, { [$style.colored]: colored }]" :style="{ background: colored ? instance.themeColor : null }"> + <img :class="$style.icon" :src="getInstanceIcon(instance)" alt=""/> + <MkA :to="`/instance-info/${instance.host}`" :class="$style.host" class="_monospace"> {{ instance.host }} </MkA> - <span class="divider"></span> + <span></span> </span> </MarqueeText> </Transition> @@ -61,46 +67,47 @@ function getInstanceIcon(instance): string { } </script> -<style lang="scss" scoped> -.change-enter-active, .change-leave-active { +<style lang="scss" module> +.transition_change_enterActive, +.transition_change_leaveActive { position: absolute; top: 0; transition: all 1s ease; } -.change-enter-from { - opacity: 0; +.transition_change_enterFrom { + opacity: 0; transform: translateY(-100%); } -.change-leave-to { - opacity: 0; +.transition_change_leaveTo { + opacity: 0; transform: translateY(100%); } -.nmidsaqw { +.root { display: inline-block; position: relative; +} - ::v-deep(.item) { - display: inline-block; - vertical-align: bottom; - margin-right: 5em; +.item { + display: inline-block; + vertical-align: bottom; + margin-right: 5em; - > .icon { - display: inline-block; - height: var(--height); - aspect-ratio: 1; - vertical-align: bottom; - margin-right: 1em; - } + &.colored { + padding-right: 1em; + color: #fff; + } +} - > .host { - vertical-align: bottom; - } +.icon { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 1em; +} - &.colored { - padding-right: 1em; - color: #fff; - } - } +.host { + vertical-align: bottom; } </style> diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 44b6b278ea..82473b609f 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -1,10 +1,16 @@ <template> -<span v-if="!fetching" class="xbhtxfms"> +<span v-if="!fetching" :class="$style.root"> <template v-if="display === 'marquee'"> - <Transition name="change" mode="default"> + <Transition + :enterActiveClass="$style.transition_change_enterActive" + :leaveActiveClass="$style.transition_change_leaveActive" + :enterFromClass="$style.transition_change_enterFrom" + :leaveToClass="$style.transition_change_leaveTo" + mode="default" + > <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> - <span v-for="item in items" class="item"> - <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + <span v-for="item in items" :class="$style.item"> + <a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> </span> </MarqueeText> </Transition> @@ -54,39 +60,40 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { }); </script> -<style lang="scss" scoped> -.change-enter-active, .change-leave-active { +<style lang="scss" module> +.transition_change_enterActive, +.transition_change_leaveActive { position: absolute; top: 0; transition: all 1s ease; } -.change-enter-from { - opacity: 0; +.transition_change_enterFrom { + opacity: 0; transform: translateY(-100%); } -.change-leave-to { - opacity: 0; +.transition_change_leaveTo { + opacity: 0; transform: translateY(100%); } -.xbhtxfms { +.root { display: inline-block; position: relative; +} - ::v-deep(.item) { - display: inline-flex; - align-items: center; - vertical-align: bottom; - margin: 0; +.item { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; +} - > .divider { - display: inline-block; - width: 0.5px; - height: var(--height); - margin: 0 3em; - background: currentColor; - opacity: 0.3; - } - } +.divider { + display: inline-block; + width: 0.5px; + height: var(--height); + margin: 0 3em; + background: currentColor; + opacity: 0.3; } </style> diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 16df69d968..9ac744943d 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -1,14 +1,20 @@ <template> -<span v-if="!fetching" class="osdsvwzy"> +<span v-if="!fetching" :class="$style.root"> <template v-if="display === 'marquee'"> - <Transition name="change" mode="default"> + <Transition + :enterActiveClass="$style.transition_change_enterActive" + :leaveActiveClass="$style.transition_change_leaveActive" + :enterFromClass="$style.transition_change_enterFrom" + :leaveToClass="$style.transition_change_leaveTo" + mode="default" + > <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> - <span v-for="note in notes" :key="note.id" class="item"> - <img class="avatar" :src="note.user.avatarUrl" decoding="async"/> - <MkA class="text" :to="notePage(note)"> - <Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true"/> + <span v-for="note in notes" :key="note.id" :class="$style.item"> + <img :class="$style.avatar" :src="note.user.avatarUrl" decoding="async"/> + <MkA :class="$style.text" :to="notePage(note)"> + <Mfm :text="getNoteSummary(note)" :plain="true" :nowrap="true"/> </MkA> - <span class="divider"></span> + <span :class="$style.divider"></span> </span> </MarqueeText> </Transition> @@ -60,54 +66,53 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { }); </script> -<style lang="scss" scoped> -.change-enter-active, .change-leave-active { +<style lang="scss" module> +.transition_change_enterActive, +.transition_change_leaveActive { position: absolute; top: 0; transition: all 1s ease; } -.change-enter-from { - opacity: 0; +.transition_change_enterFrom { + opacity: 0; transform: translateY(-100%); } -.change-leave-to { - opacity: 0; +.transition_change_leaveTo { + opacity: 0; transform: translateY(100%); } -.osdsvwzy { +.root { display: inline-block; position: relative; +} - ::v-deep(.item) { - display: inline-flex; - align-items: center; - vertical-align: bottom; - margin: 0; +.item { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; +} - > .avatar { - display: inline-block; - height: var(--height); - aspect-ratio: 1; - vertical-align: bottom; - margin-right: 8px; - } +.avatar { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 8px; +} - > .text { - > .text { - display: inline-block; - vertical-align: bottom; - } - } +.text { + display: inline-block; + vertical-align: bottom; +} - > .divider { - display: inline-block; - width: 0.5px; - height: 16px; - margin: 0 3em; - background: currentColor; - opacity: 0; - } - } +.divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 3em; + background: currentColor; + opacity: 0; } </style> diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue index f84695c15f..3533972cdf 100644 --- a/packages/frontend/src/ui/_common_/statusbars.vue +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -1,18 +1,17 @@ <template> -<div class="dlrsnxqu"> +<div :class="$style.root"> <div - v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, { - verySmall: x.size === 'verySmall', - small: x.size === 'small', - medium: x.size === 'medium', - large: x.size === 'large', - veryLarge: x.size === 'veryLarge', + v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black, + [$style.verySmall]: x.size === 'verySmall', + [$style.small]: x.size === 'small', + [$style.large]: x.size === 'large', + [$style.veryLarge]: x.size === 'veryLarge', }]" > - <span class="name">{{ x.name }}</span> - <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/> - <XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> - <XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/> + <span :class="$style.name">{{ x.name }}</span> + <XRss v-if="x.type === 'rss'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/> + <XFederation v-else-if="x.type === 'federation'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> + <XUserList v-else-if="x.type === 'userList'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :userListId="x.props.userListId"/> </div> </div> </template> @@ -25,67 +24,67 @@ const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vu const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); </script> -<style lang="scss" scoped> -.dlrsnxqu { +<style lang="scss" module> +.root { font-size: 15px; background: var(--panel); +} - > .item { - --height: 24px; - --nameMargin: 10px; - font-size: 0.85em; - - &.verySmall { - --nameMargin: 7px; - --height: 16px; - font-size: 0.75em; - } +.item { + --height: 24px; + --nameMargin: 10px; + font-size: 0.85em; - &.small { - --nameMargin: 8px; - --height: 20px; - font-size: 0.8em; - } + &.verySmall { + --nameMargin: 7px; + --height: 16px; + font-size: 0.75em; + } - &.large { - --nameMargin: 12px; - --height: 26px; - font-size: 0.875em; - } + &.small { + --nameMargin: 8px; + --height: 20px; + font-size: 0.8em; + } - &.veryLarge { - --nameMargin: 14px; - --height: 30px; - font-size: 0.9em; - } + &.large { + --nameMargin: 12px; + --height: 26px; + font-size: 0.875em; + } - display: flex; - vertical-align: bottom; - width: 100%; - line-height: var(--height); - height: var(--height); - overflow: clip; - contain: strict; + &.veryLarge { + --nameMargin: 14px; + --height: 30px; + font-size: 0.9em; + } - > .name { - padding: 0 var(--nameMargin); - font-weight: bold; - color: var(--accent); + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; - &:empty { - display: none; - } - } + &.black { + background: #000; + color: #fff; + } +} - > .body { - min-width: 0; - flex: 1; - } +.name { + padding: 0 var(--nameMargin); + font-weight: bold; + color: var(--accent); - &.black { - background: #000; - color: #fff; - } + &:empty { + display: none; } } + +.body { + min-width: 0; + flex: 1; +} </style> diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index 2a856e2a45..74c475fc7d 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -2,15 +2,15 @@ <div v-if="hasDisconnected && defaultStore.state.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected"> <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.disconnectedFromServer }}</div> <div :class="$style.command" class="_buttons"> - <MkButton :class="$style.commandButton" small primary @click="reload">{{ i18n.ts.reload }}</MkButton> - <MkButton :class="$style.commandButton" small>{{ i18n.ts.doNothing }}</MkButton> + <MkButton small primary @click="reload">{{ i18n.ts.reload }}</MkButton> + <MkButton small>{{ i18n.ts.doNothing }}</MkButton> </div> </div> </template> <script lang="ts" setup> import { onUnmounted } from 'vue'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; @@ -32,10 +32,10 @@ function reload() { location.reload(); } -stream.on('_disconnected_', onDisconnected); +useStream().on('_disconnected_', onDisconnected); onUnmounted(() => { - stream.off('_disconnected_', onDisconnected); + useStream().off('_disconnected_', onDisconnected); }); </script> @@ -54,7 +54,4 @@ onUnmounted(() => { .command { margin-top: 8px; } - -.commandButton { -} </style> diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index daea775552..747d4edcb4 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -5,18 +5,18 @@ <button v-click-anime class="item _button instance" @click="openInstanceMenu"> <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/> </button> - <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" active-class="active" to="/" exact> + <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" activeClass="active" to="/" exact> <i class="ti ti-home ti-fw"></i> </MkA> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> <i class="ti-fw" :class="navbarItemDef[item].icon"></i> <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span> </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> <i class="ti ti-dashboard ti-fw"></i> </MkA> <button v-click-anime class="item _button" @click="more"> @@ -25,7 +25,7 @@ </button> </div> <div class="right"> - <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> + <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null"> <i class="ti ti-settings ti-fw"></i> </MkA> <button v-click-anime class="item _button account" @click="openAccountMenu"> diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue index 73db14c65e..cb264fc3ba 100644 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -9,25 +9,25 @@ </MkButton> </div> <div class="divider"></div> - <MkA v-click-anime class="item index" active-class="active" to="/" exact> + <MkA v-click-anime class="item index" activeClass="active" to="/" exact> <i class="ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> </MkA> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> <i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span> <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span> </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> <i class="ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> </MkA> <button v-click-anime class="item _button" @click="more"> <i class="ti ti-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> <span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span> </button> - <MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> + <MkA v-click-anime class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null"> <i class="ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> </MkA> <div class="divider"></div> diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index 792c1ccc5e..d50f2b0454 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -7,17 +7,17 @@ <XSidebar/> </div> <div v-else ref="widgetsLeft" class="widgets left"> - <XWidgets place="left" :margin-top="'var(--margin)'" @mounted="attachSticky(widgetsLeft)"/> + <XWidgets place="left" :marginTop="'var(--margin)'" @mounted="attachSticky(widgetsLeft)"/> </div> - <main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <main class="main" @contextmenu.stop="onContextmenu"> <div class="content" style="container-type: inline-size;"> <RouterView/> </div> </main> <div v-if="isDesktop" ref="widgetsRight" class="widgets right"> - <XWidgets :place="showMenuOnTop ? 'right' : null" :margin-top="showMenuOnTop ? '0' : 'var(--margin)'" @mounted="attachSticky(widgetsRight)"/> + <XWidgets :place="showMenuOnTop ? 'right' : null" :marginTop="showMenuOnTop ? '0' : 'var(--margin)'" @mounted="attachSticky(widgetsRight)"/> </div> </div> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 33e752513b..c828731773 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -4,27 +4,23 @@ <div :class="$style.main"> <XStatusBars/> - <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 - v-if="ids.length > 1" - :class="$style.folder" - :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" - > - <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> - </section> - <DeckColumnCore - v-else - :ref="ids[0]" - :key="ids[0]" + <div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu"> + <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> + <section + v-for="ids in layout" + :class="$style.section" + :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + > + <component + :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" + v-for="id in ids" + :ref="id" + :key="id" :class="$style.column" - :column="columns.find(c => c.id === ids[0])" - :is-stacked="false" - :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" - @parent-focus="moveFocus(ids[0], $event)" + :column="columns.find(c => c.id === id)" + :isStacked="ids.length > 1" /> - </template> + </section> <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> <div>{{ i18n.ts._deck.introduction }}</div> <MkButton primary style="margin: 1em auto;" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton> @@ -53,10 +49,10 @@ </div> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" @@ -68,10 +64,10 @@ </Transition> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menu"> <XDrawerMenu/> @@ -87,7 +83,6 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store'; -import DeckColumnCore from '@/ui/deck/column-core.vue'; import XSidebar from '@/ui/_common_/navbar.vue'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import MkButton from '@/components/MkButton.vue'; @@ -100,8 +95,31 @@ import { mainRouter } from '@/router'; import { unisonReload } from '@/scripts/unison-reload'; import { deviceKind } from '@/scripts/device-kind'; import { defaultStore } from '@/store'; +import XMainColumn from '@/ui/deck/main-column.vue'; +import XTlColumn from '@/ui/deck/tl-column.vue'; +import XAntennaColumn from '@/ui/deck/antenna-column.vue'; +import XListColumn from '@/ui/deck/list-column.vue'; +import XChannelColumn from '@/ui/deck/channel-column.vue'; +import XNotificationsColumn from '@/ui/deck/notifications-column.vue'; +import XWidgetsColumn from '@/ui/deck/widgets-column.vue'; +import XMentionsColumn from '@/ui/deck/mentions-column.vue'; +import XDirectColumn from '@/ui/deck/direct-column.vue'; +import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); +const columnComponents = { + main: XMainColumn, + widgets: XWidgetsColumn, + notifications: XNotificationsColumn, + tl: XTlColumn, + list: XListColumn, + channel: XChannelColumn, + antenna: XAntennaColumn, + mentions: XMentionsColumn, + direct: XDirectColumn, + roleTimeline: XRoleTimelineColumn, +}; + mainRouter.navHook = (path, flag): boolean => { if (flag === 'forcePage') return false; const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); @@ -187,11 +205,8 @@ window.addEventListener('wheel', (ev) => { columnsEl.scrollLeft += ev.deltaY; } }); -loadDeck(); -function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { - // TODO?? -} +loadDeck(); function changeProfile(ev: MouseEvent) { const items = ref([{ @@ -267,7 +282,7 @@ async function deleteProfile() { --margin: var(--marginHalf); - --deckDividerThickness: 5px; + --columnGap: 6px; display: flex; height: 100dvh; @@ -286,19 +301,21 @@ async function deleteProfile() { flex-direction: column; } -.columns { +.sections { flex: 1; display: flex; overflow-x: auto; overflow-y: clip; + overscroll-behavior: contain; + background: var(--deckBg); &.center { - > .column:first-of-type { - margin-left: auto; + > .section:first-of-type { + margin-left: auto !important; } - > .column:last-of-type { - margin-right: auto; + > .section:last-of-type { + margin-right: auto !important; } } @@ -307,23 +324,17 @@ async function deleteProfile() { } } -.column { - scroll-snap-align: start; - flex-shrink: 0; - border-right: solid var(--deckDividerThickness) var(--deckDivider); - - &:first-of-type { - border-left: solid var(--deckDividerThickness) var(--deckDivider); - } -} - -.folder { - composes: column; +.section { display: flex; flex-direction: column; + scroll-snap-align: start; + flex-shrink: 0; + padding-top: var(--columnGap); + padding-bottom: var(--columnGap); + padding-left: var(--columnGap); - > *:not(:last-of-type) { - border-bottom: solid var(--deckDividerThickness) var(--deckDivider); + > .column:not(:last-of-type) { + margin-bottom: var(--columnGap); } } diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 76a8b6e760..d21a9cc580 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -1,10 +1,10 @@ <template> -<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :isStacked="isStacked"> <template #header> <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <MkTimeline 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"/> </XColumn> </template> @@ -21,11 +21,6 @@ const props = defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'loaded'): void; - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); onMounted(() => { diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 9605d1b22e..8b05ecc0bb 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :isStacked="isStacked"> <template #header> <i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> @@ -8,30 +8,25 @@ <div style="padding: 8px; text-align: center;"> <MkButton primary gradate rounded inline @click="post"><i class="ti ti-pencil"></i></MkButton> </div> - <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @after="() => emit('loaded')"/> + <MkTimeline ref="timeline" src="channel" :channel="column.channelId"/> </template> </XColumn> </template> <script lang="ts" setup> +import * as misskey from 'misskey-js'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store'; import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; -import * as misskey from 'misskey-js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'loaded'): void; - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); let channel = $shallowRef<misskey.entities.Channel>(); diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue deleted file mode 100644 index 8e7addf359..0000000000 --- a/packages/frontend/src/ui/deck/column-core.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<!-- TODO: リファクタの余地がありそう --> -<div v-if="!column">たぶん見えちゃいけないやつ</div> -<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XChannelColumn v-else-if="column.type === 'channel'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -<XRoleTimelineColumn v-else-if="column.type === 'roleTimeline'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import XMainColumn from './main-column.vue'; -import XTlColumn from './tl-column.vue'; -import XAntennaColumn from './antenna-column.vue'; -import XListColumn from './list-column.vue'; -import XChannelColumn from './channel-column.vue'; -import XNotificationsColumn from './notifications-column.vue'; -import XWidgetsColumn from './widgets-column.vue'; -import XMentionsColumn from './mentions-column.vue'; -import XDirectColumn from './direct-column.vue'; -import XRoleTimelineColumn from './role-timeline-column.vue'; -import { Column } from './deck-store'; - -defineProps<{ - column?: Column; - isStacked: boolean; -}>(); - -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); -</script> diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 402bbe0352..c376eb2b47 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -1,8 +1,6 @@ <template> -<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> -<section - v-hotkey="keymap" - :class="[$style.root, { [$style.paged]: isMainColumn, [$style.naked]: naked, [$style.active]: active, [$style.isStacked]: isStacked, [$style.draghover]: draghover, [$style.dragging]: dragging, [$style.dropready]: dropready }]" +<div + :class="[$style.root, { [$style.paged]: isMainColumn, [$style.naked]: naked, [$style.active]: active, [$style.draghover]: draghover, [$style.dragging]: dragging, [$style.dropready]: dropready }]" @dragover.prevent.stop="onDragover" @dragleave="onDragleave" @drop.prevent.stop="onDrop" @@ -15,17 +13,26 @@ @dragend="onDragend" @contextmenu.prevent.stop="onContextmenu" > + <svg viewBox="0 0 256 128" :class="$style.tabShape"> + <g transform="matrix(6.2431,0,0,6.2431,-677.417,-29.3839)"> + <path d="M149.512,4.707L108.507,4.707C116.252,4.719 118.758,14.958 118.758,14.958C118.758,14.958 121.381,25.283 129.009,25.209L149.512,25.209L149.512,4.707Z" style="fill:var(--deckBg);"/> + </g> + </svg> + <div :class="$style.color"></div> <button v-if="isStacked && !isMainColumn" :class="$style.toggleActive" class="_button" @click="toggleActive"> <template v-if="active"><i class="ti ti-chevron-up"></i></template> <template v-else><i class="ti ti-chevron-down"></i></template> </button> <span :class="$style.title"><slot name="header"></slot></span> + <svg viewBox="0 0 16 16" version="1.1" :class="$style.grabber"> + <path fill="currentColor" d="M10 13a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm0-4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm-4 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm5-9a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM6 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path> + </svg> <button v-tooltip="i18n.ts.settings" :class="$style.menu" class="_button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button> </header> - <div v-show="active" ref="body" :class="$style.body"> + <div v-if="active" ref="body" :class="$style.body"> <slot></slot> </div> -</section> +</div> </template> <script lang="ts" setup> @@ -49,12 +56,7 @@ const props = withDefaults(defineProps<{ naked: false, }); -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; - (ev: 'change-active-state', v: boolean): void; -}>(); - -let body = $shallowRef<HTMLDivElement>(); +let body = $shallowRef<HTMLDivElement | null>(); let dragging = $ref(false); watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd')); @@ -64,14 +66,6 @@ let dropready = $ref(false); const isMainColumn = $computed(() => props.column.type === 'main'); const active = $computed(() => props.column.active !== false); -watch($$(active), v => emit('change-active-state', v)); - -const keymap = $computed(() => ({ - 'shift+up': () => emit('parent-focus', 'up'), - 'shift+down': () => emit('parent-focus', 'down'), - 'shift+left': () => emit('parent-focus', 'left'), - 'shift+right': () => emit('parent-focus', 'right'), -})); onMounted(() => { os.deckGlobalEvents.on('column.dragStart', onOtherDragStart); @@ -190,10 +184,12 @@ function onContextmenu(ev: MouseEvent) { } function goTop() { - body.scrollTo({ - top: 0, - behavior: 'smooth', - }); + if (body) { + body.scrollTo({ + top: 0, + behavior: 'smooth', + }); + } } function onDragstart(ev) { @@ -248,6 +244,7 @@ function onDrop(ev) { height: 100%; overflow: clip; contain: strict; + border-radius: 10px; &.draghover { &:after { @@ -287,6 +284,7 @@ function onDrop(ev) { &:not(.active) { flex-basis: var(--deckColumnHeaderHeight); min-height: var(--deckColumnHeaderHeight); + border-bottom-right-radius: 0; } &.naked { @@ -299,10 +297,28 @@ function onDrop(ev) { box-shadow: none; color: var(--fg); } + + > .body { + background: transparent !important; + + &::-webkit-scrollbar-track { + background: transparent; + } + scrollbar-color: var(--scrollbarHandle) transparent; + } } &.paged { background: var(--bg) !important; + + > .body { + background: var(--bg) !important; + + &::-webkit-scrollbar-track { + background: inherit; + } + scrollbar-color: var(--scrollbarHandle) transparent; + } } } @@ -312,7 +328,7 @@ function onDrop(ev) { z-index: 2; line-height: var(--deckColumnHeaderHeight); height: var(--deckColumnHeaderHeight); - padding: 0 16px; + padding: 0 16px 0 30px; font-size: 0.9em; color: var(--panelHeaderFg); background: var(--panelHeaderBg); @@ -321,6 +337,24 @@ function onDrop(ev) { user-select: none; } +.color { + position: absolute; + top: 12px; + left: 12px; + width: 3px; + height: calc(100% - 24px); + background: var(--accent); + border-radius: 999px; +} + +.tabShape { + position: absolute; + top: 0; + right: -8px; + width: auto; + height: calc(100% - 6px); +} + .title { display: inline-block; align-items: center; @@ -335,34 +369,39 @@ function onDrop(ev) { z-index: 1; width: var(--deckColumnHeaderHeight); line-height: var(--deckColumnHeaderHeight); - color: var(--faceTextButton); - - &:hover { - color: var(--faceTextButtonHover); - } - - &:active { - color: var(--faceTextButtonActive); - } } .toggleActive { margin-left: -16px; } -.menu { +.grabber { margin-left: auto; + margin-right: 10px; + padding: 8px 8px; + box-sizing: border-box; + height: var(--deckColumnHeaderHeight); + cursor: move; + user-select: none; + opacity: 0.5; +} + +.menu { margin-right: -16px; } .body { height: calc(100% - var(--deckColumnHeaderHeight)); overflow-y: auto; - overflow-x: hidden; // Safari does not supports clip overflow-x: clip; - -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; box-sizing: border-box; container-type: size; background-color: var(--bg); + + &::-webkit-scrollbar-track { + background: var(--panel); + } + scrollbar-color: var(--scrollbarHandle) var(--panel); } </style> diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index 15b76c4d92..dc3f58e6a4 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :column="column" :isStacked="isStacked"> <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template> <MkNotes :pagination="pagination"/> @@ -17,10 +17,6 @@ defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - const pagination = { endpoint: 'notes/mentions' as const, limit: 10, diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 352c1d246a..f36dc6151c 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -1,10 +1,10 @@ <template> -<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :isStacked="isStacked"> <template #header> <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <MkTimeline 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"/> </XColumn> </template> @@ -21,11 +21,6 @@ const props = defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'loaded'): void; - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); if (props.column.listId == null) { diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index f3826a8d31..169fac70a2 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked"> <template #header> <template v-if="pageMetadata?.value"> <i :class="pageMetadata?.value.icon"></i> @@ -25,10 +25,6 @@ defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); provide('router', mainRouter); diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index 852d7a8f7e..98cf898749 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :column="column" :isStacked="isStacked"> <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template> <MkNotes :pagination="pagination"/> @@ -17,10 +17,6 @@ defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - const pagination = { endpoint: 'notes/mentions' as const, limit: 10, diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 9d133035fe..8cf6ec1f65 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -1,8 +1,8 @@ <template> -<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :column="column" :isStacked="isStacked" :menu="menu"> <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> - <XNotifications :include-types="column.includingTypes"/> + <XNotifications :includeTypes="column.includingTypes"/> </XColumn> </template> @@ -19,10 +19,6 @@ const props = defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - function func() { os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { includingTypes: props.column.includingTypes, diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 5783b3f071..a0b7f1c675 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -1,10 +1,10 @@ <template> -<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :isStacked="isStacked"> <template #header> <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @after="() => emit('loaded')"/> + <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/> </XColumn> </template> @@ -21,11 +21,6 @@ const props = defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'loaded'): void; - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); onMounted(() => { @@ -35,7 +30,7 @@ onMounted(() => { }); async function setRole() { - const roles = await os.api('roles/list'); + const roles = (await os.api('roles/list')).filter(x => x.isExplorable); const { canceled, result: role } = await os.select({ title: i18n.ts.role, items: roles.map(x => ({ diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index c23943d4db..4844ad11ff 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :isStacked="isStacked"> <template #header> <i v-if="column.tl === 'home'" class="ti ti-home"></i> <i v-else-if="column.tl === 'local'" class="ti ti-planet"></i> @@ -15,7 +15,7 @@ </p> <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> </div> - <MkTimeline 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"/> </XColumn> </template> @@ -34,11 +34,6 @@ const props = defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'loaded'): void; - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let disabled = $ref(false); const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue index 3b5b727991..da14e54f74 100644 --- a/packages/frontend/src/ui/deck/widgets-column.vue +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -1,10 +1,10 @@ <template> -<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :naked="true" :column="column" :isStacked="isStacked"> <template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template> <div :class="$style.root"> <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" :class="$style.intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> - <XWidgets :edit="edit" :widgets="column.widgets ?? []" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + <XWidgets :edit="edit" :widgets="column.widgets ?? []" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="edit = false"/> </div> </XColumn> </template> @@ -21,10 +21,6 @@ const props = defineProps<{ isStacked: boolean; }>(); -const emit = defineEmits<{ - (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; -}>(); - let edit = $ref(false); function addWidget(widget) { diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue new file mode 100644 index 0000000000..e656f00bb2 --- /dev/null +++ b/packages/frontend/src/ui/minimum.vue @@ -0,0 +1,34 @@ +<template> +<div :class="$style.root" style="container-type: inline-size;"> + <RouterView/> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { provide, ComputedRef } from 'vue'; +import XCommon from './_common_/common.vue'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; +import { instanceName } from '@/config'; + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +document.documentElement.style.overflowY = 'scroll'; +</script> + +<style lang="scss" module> +.root { + min-height: 100dvh; + box-sizing: border-box; +} +</style> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 27d0c26ac4..c0da59a57d 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -1,19 +1,15 @@ <template> -<div :class="[$style.root, { [$style.withWallpaper]: wallpaper }]"> +<div :class="$style.root"> <XSidebar v-if="!isMobile" :class="$style.sidebar"/> - <MkStickyContainer :class="$style.contents"> + <MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu"> <template #header><XStatusBars :class="$style.statusbars"/></template> - <main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> - <div :class="$style.content" style="container-type: inline-size;"> - <RouterView/> - </div> - <div :class="$style.spacer"></div> - </main> + <RouterView/> + <div :class="$style.spacer"></div> </MkStickyContainer> - <div v-if="isDesktop" ref="widgetsEl" :class="$style.widgets"> - <XWidgets :margin-top="'var(--margin)'" @mounted="attachSticky"/> + <div v-if="isDesktop" :class="$style.widgets"> + <XWidgets/> </div> <button v-if="!isDesktop && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> @@ -27,10 +23,10 @@ </div> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" @@ -42,10 +38,10 @@ </Transition> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> <XDrawerMenu/> @@ -53,10 +49,10 @@ </Transition> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" > <div v-if="widgetsShowing" @@ -68,10 +64,10 @@ </Transition> <Transition - :enter-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" - :leave-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" - :enter-from-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" - :leave-to-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''" + :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''" > <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> @@ -84,10 +80,10 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef, watch, inject, Ref } from 'vue'; +import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef, watch, shallowRef, Ref } from 'vue'; import XCommon from './_common_/common.vue'; +import type MkStickyContainer from '@/components/global/MkStickyContainer.vue'; import { instanceName } from '@/config'; -import { StickySidebar } from '@/scripts/sticky-sidebar'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; @@ -99,6 +95,7 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; import { deviceKind } from '@/scripts/device-kind'; import { miLocalStorage } from '@/local-storage'; import { CURRENT_STICKY_BOTTOM } from '@/const'; + const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); @@ -114,9 +111,9 @@ window.addEventListener('resize', () => { }); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); -const widgetsEl = $shallowRef<HTMLElement>(); const widgetsShowing = $ref(false); const navFooter = $shallowRef<HTMLElement>(); +const contents = shallowRef<InstanceType<typeof MkStickyContainer>>(); provide('router', mainRouter); provideMetadataReceiver((info) => { @@ -140,8 +137,6 @@ mainRouter.on('change', () => { drawerMenuShowing.value = false; }); -document.documentElement.style.overflowY = 'scroll'; - if (window.innerWidth > 1024) { const tempUI = miLocalStorage.getItem('ui_temp'); if (tempUI) { @@ -197,19 +192,13 @@ const onContextmenu = (ev) => { }], ev); }; -const attachSticky = (el) => { - const sticky = new StickySidebar(widgetsEl); - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); -}; - function top() { - window.scroll({ top: 0, behavior: 'smooth' }); + contents.value.rootEl.scrollTo({ + top: 0, + behavior: 'smooth', + }); } -const wallpaper = miLocalStorage.getItem('wallpaper') != null; - let navFooterHeight = $ref(0); provide<Ref<number>>(CURRENT_STICKY_BOTTOM, $$(navFooterHeight)); @@ -275,28 +264,33 @@ $widgets-hide-threshold: 1090px; } .root { - min-height: 100dvh; + height: 100dvh; + overflow: clip; + contain: strict; box-sizing: border-box; display: flex; } -.withWallpaper { - background: var(--wallpaperOverlay); - //backdrop-filter: var(--blur, blur(4px)); -} - .sidebar { border-right: solid 0.5px var(--divider); } .contents { - width: 100%; + flex: 1; + height: 100%; min-width: 0; + overflow: auto; + overflow-y: scroll; + overscroll-behavior: contain; background: var(--bg); } .widgets { - padding: 0 var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)); + width: 350px; + height: 100%; + box-sizing: border-box; + overflow: auto; + padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)); border-left: solid 0.5px var(--divider); background: var(--bg); @@ -328,6 +322,7 @@ $widgets-hide-threshold: 1090px; top: 0; right: 0; z-index: 1001; + width: 310px; height: 100dvh; padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)) !important; box-sizing: border-box; diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue index 3e0c38bb83..ec5e8bb03f 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -1,6 +1,6 @@ <template> -<div :class="$style.root" :style="{ paddingTop: marginTop }"> - <XWidgets :class="$style.widgets" :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> +<div> + <XWidgets :edit="editMode" :widgets="widgets" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="editMode = false"/> <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> @@ -11,7 +11,7 @@ let editMode = $ref(false); </script> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { } from 'vue'; import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -21,28 +21,16 @@ const props = withDefaults(defineProps<{ // left = place: leftだけを表示 // right = rightとnullを表示 place?: 'left' | null | 'right'; - marginTop?: string; }>(), { place: null, - marginTop: '0', }); -const emit = defineEmits<{ - (ev: 'mounted', el?: Element): void; -}>(); - -let rootEl = $shallowRef<HTMLDivElement>(); - const widgets = $computed(() => { if (props.place === null) return defaultStore.reactiveState.widgets.value; if (props.place === 'left') return defaultStore.reactiveState.widgets.value.filter(w => w.place === 'left'); return defaultStore.reactiveState.widgets.value.filter(w => w.place !== 'left'); }); -onMounted(() => { - emit('mounted', rootEl); -}); - function addWidget(widget) { defaultStore.set('widgets', [{ ...widget, @@ -82,16 +70,6 @@ function updateWidgets(thisWidgets) { </script> <style lang="scss" module> -.root { - position: sticky; - height: min-content; - box-sizing: border-box; -} - -.widgets { - width: 300px; -} - .edit { width: 100%; } diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue index 623abbda39..d6de145edb 100644 --- a/packages/frontend/src/ui/visitor.vue +++ b/packages/frontend/src/ui/visitor.vue @@ -12,10 +12,10 @@ <div class="main"> <div v-if="!root" class="header"> <div v-if="narrow === false" class="wide"> - <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i> {{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA> + <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA> + <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ti ti-message icon"></i> {{ i18n.ts.timeline }}</MkA> + <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA> + <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA> </div> <div v-else-if="narrow === true" class="narrow"> <button class="menu _button" @click="showMenu = true"> @@ -44,15 +44,15 @@ <Transition :name="'tray'"> <div v-if="showMenu" class="menu"> - <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> - <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> + <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> + <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> + <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> + <MkA to="/announcements" class="link" activeClass="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> + <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> <div class="divider"></div> - <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> - <MkA to="/play" class="link" active-class="active"><i class="ti ti-player-play icon"></i>Play</MkA> - <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA> + <MkA to="/pages" class="link" activeClass="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> + <MkA to="/play" class="link" activeClass="active"><i class="ti ti-player-play icon"></i>Play</MkA> + <MkA to="/gallery" class="link" activeClass="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA> <div class="action"> <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 628390e3f7..d516a5df75 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -1,9 +1,17 @@ <template> -<div class="mk-app" style="container-type: inline-size;"> +<div :class="showBottom ? $style.rootWithBottom : $style.root" style="container-type: inline-size;"> <RouterView/> <XCommon/> </div> + +<!-- + デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) + See https://github.com/misskey-dev/misskey/issues/10905 +--> +<div v-if="showBottom" :class="$style.bottom"> + <button v-tooltip="i18n.ts.goToMisskey" :class="['_button', '_shadow', $style.button]" @click="goToMisskey"><i class="ti ti-home"></i></button> +</div> </template> <script lang="ts" setup> @@ -11,10 +19,13 @@ import { provide, ComputedRef } from 'vue'; import XCommon from './_common_/common.vue'; import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; -import { instanceName } from '@/config'; +import { instanceName, ui } from '@/config'; +import { i18n } from '@/i18n'; let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +const showBottom = !(new URLSearchParams(location.search)).has('zen') && ui === 'deck'; + provide('router', mainRouter); provideMetadataReceiver((info) => { pageMetadata = info; @@ -23,12 +34,41 @@ provideMetadataReceiver((info) => { } }); +function goToMisskey() { + window.location.href = '/'; +} + document.documentElement.style.overflowY = 'scroll'; </script> -<style lang="scss" scoped> -.mk-app { +<style lang="scss" module> +.root { min-height: 100dvh; box-sizing: border-box; } + +.rootWithBottom { + min-height: calc(100dvh - (60px + (var(--margin) * 2) + env(safe-area-inset-bottom, 0px))); + box-sizing: border-box; +} + +.bottom { + height: calc(60px + (var(--margin) * 2) + env(safe-area-inset-bottom, 0px)); + width: 100%; + margin-top: auto; +} + +.button { + position: fixed !important; + padding: 0; + aspect-ratio: 1; + width: 100%; + max-width: 60px; + margin: auto; + border-radius: 100%; + background: var(--panel); + color: var(--fg); + right: var(--margin); + bottom: calc(var(--margin) + env(safe-area-inset-bottom, 0px)); +} </style> diff --git a/packages/frontend/src/unicode-emoji-indexes/en-US.json b/packages/frontend/src/unicode-emoji-indexes/en-US.json new file mode 100644 index 0000000000..c5544418db --- /dev/null +++ b/packages/frontend/src/unicode-emoji-indexes/en-US.json @@ -0,0 +1,1784 @@ +{ + "😀": ["face", "smile", "happy", "joy", ": D", "grin"], + "😬": ["face", "grimace", "teeth"], + "😁": ["face", "happy", "smile", "joy", "kawaii"], + "😂": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"], + "🤣": ["face", "rolling", "floor", "laughing", "lol", "haha"], + "🥳": ["face", "celebration", "woohoo"], + "😃": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"], + "😄": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"], + "😅": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"], + "🥲": ["face"], + "😆": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"], + "😇": ["face", "angel", "heaven", "halo"], + "😉": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"], + "😊": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"], + "🙂": ["face", "smile"], + "🙃": ["face", "flipped", "silly", "smile"], + "☺️": ["face", "blush", "massage", "happiness"], + "😋": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"], + "😌": ["face", "relaxed", "phew", "massage", "happiness"], + "😍": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"], + "🥰": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"], + "😘": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"], + "😗": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"], + "😙": ["face", "affection", "valentines", "infatuation", "kiss"], + "😚": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"], + "😜": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"], + "🤪": ["face", "goofy", "crazy"], + "🤨": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"], + "🧐": ["face", "stuffy", "wealthy"], + "😝": ["face", "prank", "playful", "mischievous", "smile", "tongue"], + "😛": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"], + "🤑": ["face", "rich", "dollar", "money"], + "🤓": ["face", "nerdy", "geek", "dork"], + "🥸": ["face", "nose", "glasses", "incognito"], + "😎": ["face", "cool", "smile", "summer", "beach", "sunglass"], + "🤩": ["face", "smile", "starry", "eyes", "grinning"], + "🤡": ["face"], + "🤠": ["face", "cowgirl", "hat"], + "🤗": ["face", "smile", "hug"], + "😏": ["face", "smile", "mean", "prank", "smug", "sarcasm"], + "😶": ["face", "hellokitty"], + "😐": ["indifference", "meh", ": |", "neutral"], + "😑": ["face", "indifferent", "-_-", "meh", "deadpan"], + "😒": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"], + "🙄": ["face", "eyeroll", "frustrated"], + "🤔": ["face", "hmmm", "think", "consider"], + "🤥": ["face", "lie", "pinocchio"], + "🤭": ["face", "whoops", "shock", "surprise"], + "🤫": ["face", "quiet", "shhh"], + "🤬": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"], + "🤯": ["face", "shocked", "mind", "blown"], + "😳": ["face", "blush", "shy", "flattered"], + "😞": ["face", "sad", "upset", "depressed", ": ("], + "😟": ["face", "concern", "nervous", ": ("], + "😠": ["mad", "face", "annoyed", "frustrated"], + "😡": ["angry", "mad", "hate", "despise"], + "😔": ["face", "sad", "depressed", "upset"], + "😕": ["face", "indifference", "huh", "weird", "hmmm", ": /"], + "🙁": ["face", "frowning", "disappointed", "sad", "upset"], + "☹": ["face", "sad", "upset", "frown"], + "😣": ["face", "sick", "no", "upset", "oops"], + "😖": ["face", "confused", "sick", "unwell", "oops", ": S"], + "😫": ["sick", "whine", "upset", "frustrated"], + "😩": ["face", "tired", "sleepy", "sad", "frustrated", "upset"], + "🥺": ["face", "begging", "mercy"], + "😤": ["face", "gas", "phew", "proud", "pride"], + "😮": ["face", "surprise", "impressed", "wow", "whoa", ": O"], + "😱": ["face", "munch", "scared", "omg"], + "😨": ["face", "scared", "terrified", "nervous", "oops", "huh"], + "😰": ["face", "nervous", "sweat"], + "😯": ["face", "woo", "shh"], + "😦": ["face", "aw", "what"], + "😧": ["face", "stunned", "nervous"], + "😢": ["face", "tears", "sad", "depressed", "upset", ": '("], + "😥": ["face", "phew", "sweat", "nervous"], + "🤤": ["face"], + "😪": ["face", "tired", "rest", "nap"], + "😓": ["face", "hot", "sad", "tired", "exercise"], + "🥵": ["face", "feverish", "heat", "red", "sweating"], + "🥶": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"], + "😭": ["face", "cry", "tears", "sad", "upset", "depressed"], + "😵": ["spent", "unconscious", "xox", "dizzy"], + "😲": ["face", "xox", "surprised", "poisoned"], + "🤐": ["face", "sealed", "zipper", "secret"], + "🤢": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"], + "🤧": ["face", "gesundheit", "sneeze", "sick", "allergy"], + "🤮": ["face", "sick"], + "😷": ["face", "sick", "ill", "disease"], + "🤒": ["sick", "temperature", "thermometer", "cold", "fever"], + "🤕": ["injured", "clumsy", "bandage", "hurt"], + "🥴": ["face", "dizzy", "intoxicated", "tipsy", "wavy"], + "🥱": ["face", "tired", "yawning"], + "😴": ["face", "tired", "sleepy", "night", "zzz"], + "💤": ["sleepy", "tired", "dream"], + "😶🌫️": [], + "😮💨": [], + "😵💫": [], + "🫠": ["disappear", "dissolve", "liquid", "melt", "toketa"], + "🫢": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"], + "🫣": ["captivated", "peep", "stare", "chunibyo"], + "🫡": ["ok", "salute", "sunny", "troops", "yes", "raja"], + "🫥": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"], + "🫤": ["disappointed", "meh", "skeptical", "unsure"], + "🥹": ["angry", "cry", "proud", "resist", "sad"], + "💩": ["hankey", "shitface", "fail", "turd", "shit"], + "😈": ["devil", "horns"], + "👿": ["devil", "angry", "horns"], + "👹": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"], + "👺": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"], + "💀": ["dead", "skeleton", "creepy", "death"], + "👻": ["halloween", "spooky", "scary"], + "👽": ["UFO", "paul", "weird", "outer_space"], + "🤖": ["computer", "machine", "bot"], + "😺": ["animal", "cats", "happy", "smile"], + "😸": ["animal", "cats", "smile"], + "😹": ["animal", "cats", "haha", "happy", "tears"], + "😻": ["animal", "love", "like", "affection", "cats", "valentines", "heart"], + "😼": ["animal", "cats", "smirk"], + "😽": ["animal", "cats", "kiss"], + "🙀": ["animal", "cats", "munch", "scared", "scream"], + "😿": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"], + "😾": ["animal", "cats"], + "🤲": ["hands", "gesture", "cupped", "prayer"], + "🙌": ["gesture", "hooray", "yea", "celebration", "hands"], + "👏": ["hands", "praise", "applause", "congrats", "yay"], + "👋": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"], + "🤙": ["hands", "gesture"], + "👍": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"], + "👎": ["thumbsdown", "no", "dislike", "hand"], + "👊": ["angry", "violence", "fist", "hit", "attack", "hand"], + "✊": ["fingers", "hand", "grasp"], + "🤛": ["hand", "fistbump"], + "🤜": ["hand", "fistbump"], + "✌": ["fingers", "ohyeah", "hand", "peace", "victory", "two"], + "👌": ["fingers", "limbs", "perfect", "ok", "okay"], + "✋": ["fingers", "stop", "highfive", "palm", "ban"], + "🤚": ["fingers", "raised", "backhand"], + "👐": ["fingers", "butterfly", "hands", "open"], + "💪": ["arm", "flex", "hand", "summer", "strong", "biceps"], + "🦾": ["flex", "hand", "strong", "biceps"], + "🙏": ["please", "hope", "wish", "namaste", "highfive"], + "🦶": ["kick", "stomp"], + "🦵": ["kick", "limb"], + "🦿": ["kick", "limb"], + "🤝": ["agreement", "shake"], + "☝": ["hand", "fingers", "direction", "up"], + "👆": ["fingers", "hand", "direction", "up"], + "👇": ["fingers", "hand", "direction", "down"], + "👈": ["direction", "fingers", "hand", "left"], + "👉": ["fingers", "hand", "direction", "right"], + "🖕": ["hand", "fingers", "rude", "middle", "flipping"], + "🖐": ["hand", "fingers", "palm"], + "🤟": ["hand", "fingers", "gesture"], + "🤘": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"], + "🤞": ["good", "lucky"], + "🖖": ["hand", "fingers", "spock", "star trek"], + "✍": ["lower_left_ballpoint_pen", "stationery", "write", "compose"], + "🫰": [], + "🫱": [], + "🫲": [], + "🫳": [], + "🫴": [], + "🫵": [], + "🫶": ["moemoekyun"], + "🤏": ["hand", "fingers"], + "🤌": ["hand", "fingers"], + "🤳": ["camera", "phone"], + "💅": ["beauty", "manicure", "finger", "fashion", "nail"], + "👄": ["mouth", "kiss"], + "🫦": [], + "🦷": ["teeth", "dentist"], + "👅": ["mouth", "playful"], + "👂": ["face", "hear", "sound", "listen"], + "🦻": ["face", "hear", "sound", "listen"], + "👃": ["smell", "sniff"], + "👁": ["face", "look", "see", "watch", "stare"], + "👀": ["look", "watch", "stalk", "peek", "see"], + "🧠": ["smart", "intelligent"], + "🫀": [], + "🫁": [], + "👤": ["user", "person", "human"], + "👥": ["user", "person", "human", "group", "team"], + "🗣": ["user", "person", "human", "sing", "say", "talk"], + "👶": ["child", "boy", "girl", "toddler"], + "🧒": ["gender-neutral", "young"], + "👦": ["man", "male", "guy", "teenager"], + "👧": ["female", "woman", "teenager"], + "🧑": ["gender-neutral", "person"], + "👨": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"], + "👩": ["female", "girls", "lady"], + "🧑🦱": ["curly", "afro", "braids", "ringlets"], + "👩🦱": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"], + "👨🦱": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"], + "🧑🦰": ["redhead"], + "👩🦰": ["woman", "female", "girl", "ginger", "redhead"], + "👨🦰": ["man", "male", "boy", "guy", "ginger", "redhead"], + "👱♀️": ["woman", "female", "girl", "blonde", "person"], + "👱": ["man", "male", "boy", "blonde", "guy", "person"], + "🧑🦳": ["gray", "old", "white"], + "👩🦳": ["woman", "female", "girl", "gray", "old", "white"], + "👨🦳": ["man", "male", "boy", "guy", "gray", "old", "white"], + "🧑🦲": ["bald", "chemotherapy", "hairless", "shaven"], + "👩🦲": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"], + "👨🦲": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"], + "🧔": ["person", "bewhiskered"], + "🧓": ["human", "elder", "senior", "gender-neutral"], + "👴": ["human", "male", "men", "old", "elder", "senior"], + "👵": ["human", "female", "women", "lady", "old", "elder", "senior"], + "👲": ["male", "boy", "chinese"], + "🧕": ["female", "hijab", "mantilla", "tichel"], + "👳♀️": ["female", "indian", "hinduism", "arabs", "woman"], + "👳": ["male", "indian", "hinduism", "arabs"], + "👮♀️": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"], + "👮": ["man", "police", "law", "legal", "enforcement", "arrest", "911"], + "👷♀️": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"], + "👷": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"], + "💂♀️": ["uk", "gb", "british", "female", "royal", "woman"], + "💂": ["uk", "gb", "british", "male", "guy", "royal"], + "🕵️♀️": ["human", "spy", "detective", "female", "woman"], + "🕵": ["human", "spy", "detective"], + "🧑⚕️": ["doctor", "nurse", "therapist", "healthcare", "human"], + "👩⚕️": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"], + "👨⚕️": ["doctor", "nurse", "therapist", "healthcare", "man", "human"], + "🧑🌾": ["rancher", "gardener", "human"], + "👩🌾": ["rancher", "gardener", "woman", "human"], + "👨🌾": ["rancher", "gardener", "man", "human"], + "🧑🍳": ["chef", "human"], + "👩🍳": ["chef", "woman", "human"], + "👨🍳": ["chef", "man", "human"], + "🧑🎓": ["graduate", "human"], + "👩🎓": ["graduate", "woman", "human"], + "👨🎓": ["graduate", "man", "human"], + "🧑🎤": ["rockstar", "entertainer", "human"], + "👩🎤": ["rockstar", "entertainer", "woman", "human"], + "👨🎤": ["rockstar", "entertainer", "man", "human"], + "🧑🏫": ["instructor", "professor", "human"], + "👩🏫": ["instructor", "professor", "woman", "human"], + "👨🏫": ["instructor", "professor", "man", "human"], + "🧑🏭": ["assembly", "industrial", "human"], + "👩🏭": ["assembly", "industrial", "woman", "human"], + "👨🏭": ["assembly", "industrial", "man", "human"], + "🧑💻": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"], + "👩💻": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"], + "👨💻": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"], + "🧑💼": ["business", "manager", "human"], + "👩💼": ["business", "manager", "woman", "human"], + "👨💼": ["business", "manager", "man", "human"], + "🧑🔧": ["plumber", "human", "wrench"], + "👩🔧": ["plumber", "woman", "human", "wrench"], + "👨🔧": ["plumber", "man", "human", "wrench"], + "🧑🔬": ["biologist", "chemist", "engineer", "physicist", "human"], + "👩🔬": ["biologist", "chemist", "engineer", "physicist", "woman", "human"], + "👨🔬": ["biologist", "chemist", "engineer", "physicist", "man", "human"], + "🧑🎨": ["painter", "human"], + "👩🎨": ["painter", "woman", "human"], + "👨🎨": ["painter", "man", "human"], + "🧑🚒": ["fireman", "human"], + "👩🚒": ["fireman", "woman", "human"], + "👨🚒": ["fireman", "man", "human"], + "🧑✈️": ["aviator", "plane", "human"], + "👩✈️": ["aviator", "plane", "woman", "human"], + "👨✈️": ["aviator", "plane", "man", "human"], + "🧑🚀": ["space", "rocket", "human"], + "👩🚀": ["space", "rocket", "woman", "human"], + "👨🚀": ["space", "rocket", "man", "human"], + "🧑⚖️": ["justice", "court", "human"], + "👩⚖️": ["justice", "court", "woman", "human"], + "👨⚖️": ["justice", "court", "man", "human"], + "🦸♀️": ["woman", "female", "good", "heroine", "superpowers"], + "🦸♂️": ["man", "male", "good", "hero", "superpowers"], + "🦹♀️": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"], + "🦹♂️": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"], + "🤶": ["woman", "female", "xmas", "mother christmas"], + "🧑🎄": ["xmas", "christmas"], + "🎅": ["festival", "man", "male", "xmas", "father christmas"], + "🥷": [], + "🧙♀️": ["woman", "female", "mage", "witch"], + "🧙♂️": ["man", "male", "mage", "sorcerer"], + "🧝♀️": ["woman", "female"], + "🧝♂️": ["man", "male"], + "🧛♀️": ["woman", "female"], + "🧛♂️": ["man", "male", "dracula"], + "🧟♀️": ["woman", "female", "undead", "walking dead"], + "🧟♂️": ["man", "male", "dracula", "undead", "walking dead"], + "🧞♀️": ["woman", "female"], + "🧞♂️": ["man", "male"], + "🧜♀️": ["woman", "female", "merwoman", "ariel"], + "🧜♂️": ["man", "male", "triton"], + "🧚♀️": ["woman", "female"], + "🧚♂️": ["man", "male"], + "👼": ["heaven", "wings", "halo"], + "🧌": [], + "🤰": ["baby"], + "🫃": [], + "🫄": [], + "🫅": [], + "🤱": ["nursing", "baby"], + "👩🍼": [], + "👨🍼": [], + "🧑🍼": [], + "👸": ["girl", "woman", "female", "blond", "crown", "royal", "queen"], + "🤴": ["boy", "man", "male", "crown", "royal", "king"], + "👰": ["couple", "marriage", "wedding", "woman", "bride"], + "👰": ["couple", "marriage", "wedding", "woman", "bride"], + "🤵": ["couple", "marriage", "wedding", "groom"], + "🤵": ["couple", "marriage", "wedding", "groom"], + "🏃♀️": ["woman", "walking", "exercise", "race", "running", "female"], + "🏃": ["man", "walking", "exercise", "race", "running"], + "🚶♀️": ["human", "feet", "steps", "woman", "female"], + "🚶": ["human", "feet", "steps"], + "💃": ["female", "girl", "woman", "fun"], + "🕺": ["male", "boy", "fun", "dancer"], + "👯": ["female", "bunny", "women", "girls"], + "👯♂️": ["male", "bunny", "men", "boys"], + "👫": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"], + "🧑🤝🧑": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"], + "👬": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"], + "👭": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"], + "🫂": [], + "🙇♀️": ["woman", "female", "girl"], + "🙇": ["man", "male", "boy"], + "🤦♂️": ["man", "male", "boy", "disbelief"], + "🤦♀️": ["woman", "female", "girl", "disbelief"], + "🤷": ["woman", "female", "girl", "confused", "indifferent", "doubt"], + "🤷♂️": ["man", "male", "boy", "confused", "indifferent", "doubt"], + "💁": ["female", "girl", "woman", "human", "information"], + "💁♂️": ["male", "boy", "man", "human", "information"], + "🙅": ["female", "girl", "woman", "nope"], + "🙅♂️": ["male", "boy", "man", "nope"], + "🙆": ["women", "girl", "female", "pink", "human", "woman"], + "🙆♂️": ["men", "boy", "male", "blue", "human", "man"], + "🙋": ["female", "girl", "woman"], + "🙋♂️": ["male", "boy", "man"], + "🙎": ["female", "girl", "woman"], + "🙎♂️": ["male", "boy", "man"], + "🙍": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"], + "🙍♂️": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"], + "💇": ["female", "girl", "woman"], + "💇♂️": ["male", "boy", "man"], + "💆": ["female", "girl", "woman", "head"], + "💆♂️": ["male", "boy", "man", "head"], + "🧖♀️": ["female", "woman", "spa", "steamroom", "sauna"], + "🧖♂️": ["male", "man", "spa", "steamroom", "sauna"], + "🧏♀️": ["woman", "female"], + "🧏♂️": ["man", "male"], + "🧍♀️": ["woman", "female"], + "🧍♂️": ["man", "male"], + "🧎♀️": ["woman", "female"], + "🧎♂️": ["man", "male"], + "🧑🦯": ["accessibility", "blind"], + "👩🦯": ["woman", "female", "accessibility", "blind"], + "👨🦯": ["man", "male", "accessibility", "blind"], + "🧑🦼": ["accessibility"], + "👩🦼": ["woman", "female", "accessibility"], + "👨🦼": ["man", "male", "accessibility"], + "🧑🦽": ["accessibility"], + "👩🦽": ["woman", "female", "accessibility"], + "👨🦽": ["man", "male", "accessibility"], + "💑": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"], + "👩❤️👩": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"], + "👨❤️👨": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"], + "💏": ["pair", "valentines", "love", "like", "dating", "marriage"], + "👩❤️💋👩": ["pair", "valentines", "love", "like", "dating", "marriage"], + "👨❤️💋👨": ["pair", "valentines", "love", "like", "dating", "marriage"], + "👪": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"], + "👨👩👧": ["home", "parents", "people", "human", "child"], + "👨👩👧👦": ["home", "parents", "people", "human", "children"], + "👨👩👦👦": ["home", "parents", "people", "human", "children"], + "👨👩👧👧": ["home", "parents", "people", "human", "children"], + "👩👩👦": ["home", "parents", "people", "human", "children"], + "👩👩👧": ["home", "parents", "people", "human", "children"], + "👩👩👧👦": ["home", "parents", "people", "human", "children"], + "👩👩👦👦": ["home", "parents", "people", "human", "children"], + "👩👩👧👧": ["home", "parents", "people", "human", "children"], + "👨👨👦": ["home", "parents", "people", "human", "children"], + "👨👨👧": ["home", "parents", "people", "human", "children"], + "👨👨👧👦": ["home", "parents", "people", "human", "children"], + "👨👨👦👦": ["home", "parents", "people", "human", "children"], + "👨👨👧👧": ["home", "parents", "people", "human", "children"], + "👩👦": ["home", "parent", "people", "human", "child"], + "👩👧": ["home", "parent", "people", "human", "child"], + "👩👧👦": ["home", "parent", "people", "human", "children"], + "👩👦👦": ["home", "parent", "people", "human", "children"], + "👩👧👧": ["home", "parent", "people", "human", "children"], + "👨👦": ["home", "parent", "people", "human", "child"], + "👨👧": ["home", "parent", "people", "human", "child"], + "👨👧👦": ["home", "parent", "people", "human", "children"], + "👨👦👦": ["home", "parent", "people", "human", "children"], + "👨👧👧": ["home", "parent", "people", "human", "children"], + "🧶": ["ball", "crochet", "knit"], + "🧵": ["needle", "sewing", "spool", "string"], + "🧥": ["jacket"], + "🥼": ["doctor", "experiment", "scientist", "chemist"], + "👚": ["fashion", "shopping_bags", "female"], + "👕": ["fashion", "cloth", "casual", "shirt", "tee"], + "👖": ["fashion", "shopping"], + "👔": ["shirt", "suitup", "formal", "fashion", "cloth", "business"], + "👗": ["clothes", "fashion", "shopping"], + "👙": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"], + "🩱": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"], + "👘": ["dress", "fashion", "women", "female", "japanese"], + "🥻": ["dress", "fashion", "women", "female"], + "🩲": ["dress", "fashion"], + "🩳": ["dress", "fashion"], + "💄": ["female", "girl", "fashion", "woman"], + "💋": ["face", "lips", "love", "like", "affection", "valentines"], + "👣": ["feet", "tracking", "walking", "beach"], + "🥿": ["ballet", "slip-on", "slipper"], + "👠": ["fashion", "shoes", "female", "pumps", "stiletto"], + "👡": ["shoes", "fashion", "flip flops"], + "👢": ["shoes", "fashion"], + "👞": ["fashion", "male"], + "👟": ["shoes", "sports", "sneakers"], + "🩴": [], + "🩰": ["shoes", "sports"], + "🧦": ["stockings", "clothes"], + "🧤": ["hands", "winter", "clothes"], + "🧣": ["neck", "winter", "clothes"], + "👒": ["fashion", "accessories", "female", "lady", "spring"], + "🎩": ["magic", "gentleman", "classy", "circus"], + "🧢": ["cap", "baseball"], + "⛑": ["construction", "build"], + "🪖": [], + "🎓": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"], + "👑": ["king", "kod", "leader", "royalty", "lord"], + "🎒": ["student", "education", "bag", "backpack"], + "🧳": ["packing", "travel"], + "👝": ["bag", "accessories", "shopping"], + "👛": ["fashion", "accessories", "money", "sales", "shopping"], + "👜": ["fashion", "accessory", "accessories", "shopping"], + "💼": ["business", "documents", "work", "law", "legal", "job", "career"], + "👓": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"], + "🕶": ["face", "cool", "accessories"], + "🥽": ["eyes", "protection", "safety"], + "💍": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"], + "🌂": ["weather", "rain", "drizzle"], + "🐶": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"], + "🐱": ["animal", "meow", "nature", "pet", "kitten"], + "🐈⬛": ["animal", "meow", "nature", "pet", "kitten"], + "🐭": ["animal", "nature", "cheese_wedge", "rodent"], + "🐹": ["animal", "nature"], + "🐰": ["animal", "nature", "pet", "spring", "magic", "bunny"], + "🦊": ["animal", "nature", "face"], + "🐻": ["animal", "nature", "wild"], + "🐼": ["animal", "nature", "panda"], + "🐨": ["animal", "nature"], + "🐯": ["animal", "cat", "danger", "wild", "nature", "roar"], + "🦁": ["animal", "nature"], + "🐮": ["beef", "ox", "animal", "nature", "moo", "milk"], + "🐷": ["animal", "oink", "nature"], + "🐽": ["animal", "oink"], + "🐸": ["animal", "nature", "croak", "toad"], + "🦑": ["animal", "nature", "ocean", "sea"], + "🐙": ["animal", "creature", "ocean", "sea", "nature", "beach"], + "🦐": ["animal", "ocean", "nature", "seafood"], + "🐵": ["animal", "nature", "circus"], + "🦍": ["animal", "nature", "circus"], + "🙈": ["monkey", "animal", "nature", "haha"], + "🙉": ["animal", "monkey", "nature"], + "🙊": ["monkey", "animal", "nature", "omg"], + "🐒": ["animal", "nature", "banana", "circus"], + "🐔": ["animal", "cluck", "nature", "bird"], + "🐧": ["animal", "nature"], + "🐦": ["animal", "nature", "fly", "tweet", "spring"], + "🐤": ["animal", "chicken", "bird"], + "🐣": ["animal", "chicken", "egg", "born", "baby", "bird"], + "🐥": ["animal", "chicken", "baby", "bird"], + "🦆": ["animal", "nature", "bird", "mallard"], + "🦅": ["animal", "nature", "bird"], + "🦉": ["animal", "nature", "bird", "hoot"], + "🦇": ["animal", "nature", "blind", "vampire"], + "🐺": ["animal", "nature", "wild"], + "🐗": ["animal", "nature"], + "🐴": ["animal", "brown", "nature"], + "🦄": ["animal", "nature", "mystical"], + "🐝": ["animal", "insect", "nature", "bug", "spring", "honey"], + "🐛": ["animal", "insect", "nature", "worm"], + "🦋": ["animal", "insect", "nature", "caterpillar"], + "🐌": ["slow", "animal", "shell"], + "🐞": ["animal", "insect", "nature", "ladybug"], + "🐜": ["animal", "insect", "nature", "bug"], + "🦗": ["animal", "cricket", "chirp"], + "🕷": ["animal", "arachnid"], + "🪲": ["animal"], + "🪳": ["animal"], + "🪰": ["animal"], + "🪱": ["animal"], + "🦂": ["animal", "arachnid"], + "🦀": ["animal", "crustacean"], + "🐍": ["animal", "evil", "nature", "hiss", "python"], + "🦎": ["animal", "nature", "reptile"], + "🦖": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"], + "🦕": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"], + "🐢": ["animal", "slow", "nature", "tortoise"], + "🐠": ["animal", "swim", "ocean", "beach", "nemo"], + "🐟": ["animal", "food", "nature"], + "🐡": ["animal", "nature", "food", "sea", "ocean"], + "🐬": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"], + "🦈": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"], + "🐳": ["animal", "nature", "sea", "ocean"], + "🐋": ["animal", "nature", "sea", "ocean"], + "🐊": ["animal", "nature", "reptile", "lizard", "alligator"], + "🐆": ["animal", "nature"], + "🦓": ["animal", "nature", "stripes", "safari"], + "🐅": ["animal", "nature", "roar"], + "🐃": ["animal", "nature", "ox", "cow"], + "🐂": ["animal", "cow", "beef"], + "🐄": ["beef", "ox", "animal", "nature", "moo", "milk"], + "🦌": ["animal", "nature", "horns", "venison"], + "🐪": ["animal", "hot", "desert", "hump"], + "🐫": ["animal", "nature", "hot", "desert", "hump"], + "🦒": ["animal", "nature", "spots", "safari"], + "🐘": ["animal", "nature", "nose", "th", "circus"], + "🦏": ["animal", "nature", "horn"], + "🐐": ["animal", "nature"], + "🐏": ["animal", "sheep", "nature"], + "🐑": ["animal", "nature", "wool", "shipit"], + "🐎": ["animal", "gamble", "luck"], + "🐖": ["animal", "nature"], + "🐀": ["animal", "mouse", "rodent"], + "🐁": ["animal", "nature", "rodent"], + "🐓": ["animal", "nature", "chicken"], + "🦃": ["animal", "bird"], + "🕊": ["animal", "bird"], + "🐕": ["animal", "nature", "friend", "doge", "pet", "faithful"], + "🐩": ["dog", "animal", "101", "nature", "pet"], + "🐈": ["animal", "meow", "pet", "cats"], + "🐇": ["animal", "nature", "pet", "magic", "spring"], + "🐿": ["animal", "nature", "rodent", "squirrel"], + "🦔": ["animal", "nature", "spiny"], + "🦝": ["animal", "nature"], + "🦙": ["animal", "nature", "alpaca"], + "🦛": ["animal", "nature"], + "🦘": ["animal", "nature", "australia", "joey", "hop", "marsupial"], + "🦡": ["animal", "nature", "honey"], + "🦢": ["animal", "nature", "bird"], + "🦚": ["animal", "nature", "peahen", "bird"], + "🦜": ["animal", "nature", "bird", "pirate", "talk"], + "🦞": ["animal", "nature", "bisque", "claws", "seafood"], + "🦠": ["amoeba", "bacteria", "germs"], + "🦟": ["animal", "nature", "insect", "malaria"], + "🦬": ["animal", "nature"], + "🦣": ["animal", "nature"], + "🦫": ["animal", "nature"], + "🐻❄️": ["animal", "nature"], + "🦤": ["animal", "nature"], + "🪶": ["animal", "nature"], + "🦭": ["animal", "nature"], + "🐾": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"], + "🐉": ["animal", "myth", "nature", "chinese", "green"], + "🐲": ["animal", "myth", "nature", "chinese", "green"], + "🦧": ["animal", "nature"], + "🦮": ["animal", "nature"], + "🐕🦺": ["animal", "nature"], + "🦥": ["animal", "nature"], + "🦦": ["animal", "nature"], + "🦨": ["animal", "nature"], + "🦩": ["animal", "nature"], + "🌵": ["vegetable", "plant", "nature"], + "🎄": ["festival", "vacation", "december", "xmas", "celebration"], + "🌲": ["plant", "nature"], + "🌳": ["plant", "nature"], + "🌴": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"], + "🌱": ["plant", "nature", "grass", "lawn", "spring"], + "🌿": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"], + "☘": ["vegetable", "plant", "nature", "irish", "clover"], + "🍀": ["vegetable", "plant", "nature", "lucky", "irish"], + "🎍": ["plant", "nature", "vegetable", "panda", "pine_decoration"], + "🎋": ["plant", "nature", "branch", "summer"], + "🍃": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"], + "🍂": ["nature", "plant", "vegetable", "leaves"], + "🍁": ["nature", "plant", "vegetable", "ca", "fall"], + "🌾": ["nature", "plant"], + "🌺": ["plant", "vegetable", "flowers", "beach"], + "🌻": ["nature", "plant", "fall"], + "🌹": ["flowers", "valentines", "love", "spring"], + "🥀": ["plant", "nature", "flower"], + "🌷": ["flowers", "plant", "nature", "summer", "spring"], + "🌼": ["nature", "flowers", "yellow"], + "🌸": ["nature", "plant", "spring", "flower"], + "💐": ["flowers", "nature", "spring"], + "🍄": ["plant", "vegetable"], + "🪴": ["plant"], + "🌰": ["food", "squirrel"], + "🎃": ["halloween", "light", "pumpkin", "creepy", "fall"], + "🐚": ["nature", "sea", "beach"], + "🕸": ["animal", "insect", "arachnid", "silk"], + "🌎": ["globe", "world", "USA", "international"], + "🌍": ["globe", "world", "international"], + "🌏": ["globe", "world", "east", "international"], + "🪐": ["saturn"], + "🌕": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌖": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"], + "🌗": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌘": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌑": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌒": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌓": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌔": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"], + "🌚": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌝": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌛": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌜": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"], + "🌞": ["nature", "morning", "sky"], + "🌙": ["night", "sleep", "sky", "evening", "magic"], + "⭐": ["night", "yellow"], + "🌟": ["night", "sparkle", "awesome", "good", "magic"], + "💫": ["star", "sparkle", "shoot", "magic"], + "✨": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"], + "☄": ["space"], + "☀️": ["weather", "nature", "brightness", "summer", "beach", "spring"], + "🌤": ["weather"], + "⛅": ["weather", "nature", "cloudy", "morning", "fall", "spring"], + "🌥": ["weather"], + "🌦": ["weather"], + "☁️": ["weather", "sky"], + "🌧": ["weather"], + "⛈": ["weather", "lightning"], + "🌩": ["weather", "thunder"], + "⚡": ["thunder", "weather", "lightning bolt", "fast"], + "🔥": ["hot", "cook", "flame"], + "💥": ["bomb", "explode", "explosion", "collision", "blown"], + "❄️": ["winter", "season", "cold", "weather", "christmas", "xmas"], + "🌨": ["weather"], + "⛄": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"], + "☃": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"], + "🌬": ["gust", "air"], + "💨": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"], + "🌪": ["weather", "cyclone", "twister"], + "🌫": ["weather"], + "☂": ["weather", "spring"], + "☔": ["rainy", "weather", "spring"], + "💧": ["water", "drip", "faucet", "spring"], + "💦": ["water", "drip", "oops"], + "🌊": ["sea", "water", "wave", "nature", "tsunami", "disaster"], + "🪷": [], + "🪸": [], + "🪹": [], + "🪺": [], + "🍏": ["fruit", "nature"], + "🍎": ["fruit", "mac", "school"], + "🍐": ["fruit", "nature", "food"], + "🍊": ["food", "fruit", "nature", "orange"], + "🍋": ["fruit", "nature"], + "🍌": ["fruit", "food", "monkey"], + "🍉": ["fruit", "food", "picnic", "summer"], + "🍇": ["fruit", "food", "wine"], + "🍓": ["fruit", "food", "nature"], + "🍈": ["fruit", "nature", "food"], + "🍒": ["food", "fruit"], + "🍑": ["fruit", "nature", "food"], + "🍍": ["fruit", "nature", "food"], + "🥥": ["fruit", "nature", "food", "palm"], + "🥝": ["fruit", "food"], + "🥭": ["fruit", "food", "tropical"], + "🥑": ["fruit", "food"], + "🥦": ["fruit", "food", "vegetable"], + "🍅": ["fruit", "vegetable", "nature", "food"], + "🍆": ["vegetable", "nature", "food", "aubergine"], + "🥒": ["fruit", "food", "pickle"], + "🫐": ["fruit", "food"], + "🫒": ["fruit", "food"], + "🫑": ["fruit", "food"], + "🥕": ["vegetable", "food", "orange"], + "🌶": ["food", "spicy", "chilli", "chili"], + "🥔": ["food", "tuber", "vegatable", "starch"], + "🌽": ["food", "vegetable", "plant"], + "🥬": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"], + "🍠": ["food", "nature"], + "🥜": ["food", "nut"], + "🧄": ["food"], + "🧅": ["food"], + "🍯": ["bees", "sweet", "kitchen"], + "🥐": ["food", "bread", "french"], + "🍞": ["food", "wheat", "breakfast", "toast"], + "🥖": ["food", "bread", "french"], + "🥯": ["food", "bread", "bakery", "schmear"], + "🥨": ["food", "bread", "twisted"], + "🧀": ["food", "chadder"], + "🥚": ["food", "chicken", "breakfast"], + "🥓": ["food", "breakfast", "pork", "pig", "meat"], + "🥩": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"], + "🥞": ["food", "breakfast", "flapjacks", "hotcakes"], + "🍗": ["food", "meat", "drumstick", "bird", "chicken", "turkey"], + "🍖": ["good", "food", "drumstick"], + "🦴": ["skeleton"], + "🍤": ["food", "animal", "appetizer", "summer"], + "🍳": ["food", "breakfast", "kitchen", "egg"], + "🍔": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"], + "🍟": ["chips", "snack", "fast food"], + "🥙": ["food", "flatbread", "stuffed", "gyro"], + "🌭": ["food", "frankfurter"], + "🍕": ["food", "party"], + "🥪": ["food", "lunch", "bread"], + "🥫": ["food", "soup"], + "🍝": ["food", "italian", "noodle"], + "🌮": ["food", "mexican"], + "🌯": ["food", "mexican"], + "🥗": ["food", "healthy", "lettuce"], + "🥘": ["food", "cooking", "casserole", "paella"], + "🍜": ["food", "japanese", "noodle", "chopsticks"], + "🍲": ["food", "meat", "soup"], + "🍥": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"], + "🥠": ["food", "prophecy"], + "🍣": ["food", "fish", "japanese", "rice"], + "🍱": ["food", "japanese", "box"], + "🍛": ["food", "spicy", "hot", "indian"], + "🍙": ["food", "japanese"], + "🍚": ["food", "china", "asian"], + "🍘": ["food", "japanese"], + "🍢": ["food", "japanese"], + "🍡": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"], + "🍧": ["hot", "dessert", "summer"], + "🍨": ["food", "hot", "dessert"], + "🍦": ["food", "hot", "dessert", "summer"], + "🥧": ["food", "dessert", "pastry"], + "🍰": ["food", "dessert"], + "🧁": ["food", "dessert", "bakery", "sweet"], + "🥮": ["food", "autumn"], + "🎂": ["food", "dessert", "cake"], + "🍮": ["dessert", "food"], + "🍬": ["snack", "dessert", "sweet", "lolly"], + "🍭": ["food", "snack", "candy", "sweet"], + "🍫": ["food", "snack", "dessert", "sweet"], + "🍿": ["food", "movie theater", "films", "snack"], + "🥟": ["food", "empanada", "pierogi", "potsticker"], + "🍩": ["food", "dessert", "snack", "sweet", "donut"], + "🍪": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"], + "🧇": ["food"], + "🧆": ["food"], + "🧈": ["food"], + "🦪": ["food"], + "🫓": ["food"], + "🫔": ["food"], + "🫕": ["food"], + "🥛": ["beverage", "drink", "cow"], + "🍺": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"], + "🍻": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"], + "🥂": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"], + "🍷": ["drink", "beverage", "drunk", "alcohol", "booze"], + "🥃": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"], + "🍸": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"], + "🍹": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"], + "🍾": ["drink", "wine", "bottle", "celebration"], + "🍶": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"], + "🍵": ["drink", "bowl", "breakfast", "green", "british"], + "🥤": ["drink", "soda"], + "☕": ["beverage", "caffeine", "latte", "espresso"], + "🫖": [], + "🧋": ["tapioca"], + "🍼": ["food", "container", "milk"], + "🧃": ["food", "drink"], + "🧉": ["food", "drink"], + "🧊": ["food"], + "🧂": ["condiment", "shaker"], + "🥄": ["cutlery", "kitchen", "tableware"], + "🍴": ["cutlery", "kitchen"], + "🍽": ["food", "eat", "meal", "lunch", "dinner", "restaurant"], + "🥣": ["food", "breakfast", "cereal", "oatmeal", "porridge"], + "🥡": ["food", "leftovers"], + "🥢": ["food"], + "🫗": [], + "🫘": [], + "🫙": [], + "⚽": ["sports", "football"], + "🏀": ["sports", "balls", "NBA"], + "🏈": ["sports", "balls", "NFL"], + "⚾": ["sports", "balls"], + "🥎": ["sports", "balls"], + "🎾": ["sports", "balls", "green"], + "🏐": ["sports", "balls"], + "🏉": ["sports", "team"], + "🥏": ["sports", "frisbee", "ultimate"], + "🎱": ["pool", "hobby", "game", "luck", "magic"], + "⛳": ["sports", "business", "flag", "hole", "summer"], + "🏌️♀️": ["sports", "business", "woman", "female"], + "🏌": ["sports", "business"], + "🏓": ["sports", "pingpong"], + "🏸": ["sports"], + "🥅": ["sports"], + "🏒": ["sports"], + "🏑": ["sports"], + "🥍": ["sports", "ball", "stick"], + "🏏": ["sports"], + "🎿": ["sports", "winter", "cold", "snow"], + "⛷": ["sports", "winter", "snow"], + "🏂": ["sports", "winter"], + "🤺": ["sports", "fencing", "sword"], + "🤼♀️": ["sports", "wrestlers"], + "🤼♂️": ["sports", "wrestlers"], + "🤸♀️": ["gymnastics"], + "🤸♂️": ["gymnastics"], + "🤾♀️": ["sports"], + "🤾♂️": ["sports"], + "⛸": ["sports"], + "🥌": ["sports"], + "🛹": ["board"], + "🛷": ["sleigh", "luge", "toboggan"], + "🏹": ["sports"], + "🎣": ["food", "hobby", "summer"], + "🥊": ["sports", "fighting"], + "🥋": ["judo", "karate", "taekwondo"], + "🚣♀️": ["sports", "hobby", "water", "ship", "woman", "female"], + "🚣": ["sports", "hobby", "water", "ship"], + "🧗♀️": ["sports", "hobby", "woman", "female", "rock"], + "🧗♂️": ["sports", "hobby", "man", "male", "rock"], + "🏊♀️": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"], + "🏊": ["sports", "exercise", "human", "athlete", "water", "summer"], + "🤽♀️": ["sports", "pool"], + "🤽♂️": ["sports", "pool"], + "🧘♀️": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"], + "🧘♂️": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"], + "🏄♀️": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"], + "🏄": ["sports", "ocean", "sea", "summer", "beach"], + "🛀": ["clean", "shower", "bathroom"], + "⛹️♀️": ["sports", "human", "woman", "female"], + "⛹": ["sports", "human"], + "🏋️♀️": ["sports", "training", "exercise", "woman", "female"], + "🏋": ["sports", "training", "exercise"], + "🚴♀️": ["sports", "bike", "exercise", "hipster", "woman", "female"], + "🚴": ["sports", "bike", "exercise", "hipster"], + "🚵♀️": ["transportation", "sports", "human", "race", "bike", "woman", "female"], + "🚵": ["transportation", "sports", "human", "race", "bike"], + "🏇": ["animal", "betting", "competition", "gambling", "luck"], + "🤿": ["sports"], + "🪀": ["sports"], + "🪁": ["sports"], + "🦺": ["sports"], + "🪡": [], + "🪢": [], + "🕴": ["suit", "business", "levitate", "hover", "jump"], + "🏆": ["win", "award", "contest", "place", "ftw", "ceremony"], + "🎽": ["play", "pageant"], + "🏅": ["award", "winning"], + "🎖": ["award", "winning", "army"], + "🥇": ["award", "winning", "first"], + "🥈": ["award", "second"], + "🥉": ["award", "third"], + "🎗": ["sports", "cause", "support", "awareness"], + "🏵": ["flower", "decoration", "military"], + "🎫": ["event", "concert", "pass"], + "🎟": ["sports", "concert", "entrance"], + "🎭": ["acting", "theater", "drama"], + "🎨": ["design", "paint", "draw", "colors"], + "🎪": ["festival", "carnival", "party"], + "🤹♀️": ["juggle", "balance", "skill", "multitask"], + "🤹♂️": ["juggle", "balance", "skill", "multitask"], + "🎤": ["sound", "music", "PA", "sing", "talkshow"], + "🎧": ["music", "score", "gadgets"], + "🎼": ["treble", "clef", "compose"], + "🎹": ["piano", "instrument", "compose"], + "🥁": ["music", "instrument", "drumsticks", "snare"], + "🎷": ["music", "instrument", "jazz", "blues"], + "🎺": ["music", "brass"], + "🎸": ["music", "instrument"], + "🎻": ["music", "instrument", "orchestra", "symphony"], + "🪕": ["music", "instrument"], + "🪗": ["music", "instrument"], + "🪘": ["music", "instrument"], + "🎬": ["movie", "film", "record"], + "🎮": ["play", "console", "PS4", "controller"], + "👾": ["game", "arcade", "play"], + "🎯": ["game", "play", "bar", "target", "bullseye"], + "🎲": ["dice", "random", "tabletop", "play", "luck"], + "♟️": ["expendable"], + "🎰": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"], + "🧩": ["interlocking", "puzzle", "piece"], + "🎳": ["sports", "fun", "play"], + "🪄": [], + "🪅": [], + "🪆": [], + "🪬": [], + "🪩": [], + "🚗": ["red", "transportation", "vehicle"], + "🚕": ["uber", "vehicle", "cars", "transportation"], + "🚙": ["transportation", "vehicle"], + "🚌": ["car", "vehicle", "transportation"], + "🚎": ["bart", "transportation", "vehicle"], + "🏎": ["sports", "race", "fast", "formula", "f1"], + "🚓": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"], + "🚑": ["health", "911", "hospital"], + "🚒": ["transportation", "cars", "vehicle"], + "🚐": ["vehicle", "car", "transportation"], + "🚚": ["cars", "transportation"], + "🚛": ["vehicle", "cars", "transportation", "express"], + "🚜": ["vehicle", "car", "farming", "agriculture"], + "🛴": ["vehicle", "kick", "razor"], + "🏍": ["race", "sports", "fast"], + "🚲": ["sports", "bicycle", "exercise", "hipster"], + "🛵": ["vehicle", "vespa", "sasha"], + "🦽": ["vehicle"], + "🦼": ["vehicle"], + "🛺": ["vehicle"], + "🪂": ["vehicle"], + "🚨": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"], + "🚔": ["vehicle", "law", "legal", "enforcement", "911"], + "🚍": ["vehicle", "transportation"], + "🚘": ["car", "vehicle", "transportation"], + "🚖": ["vehicle", "cars", "uber"], + "🚡": ["transportation", "vehicle", "ski"], + "🚠": ["transportation", "vehicle", "ski"], + "🚟": ["vehicle", "transportation"], + "🚃": ["transportation", "vehicle", "train"], + "🚋": ["transportation", "vehicle", "carriage", "public", "travel"], + "🚝": ["transportation", "vehicle"], + "🚄": ["transportation", "vehicle"], + "🚅": ["transportation", "vehicle", "speed", "fast", "public", "travel"], + "🚈": ["transportation", "vehicle"], + "🚞": ["transportation", "vehicle"], + "🚂": ["transportation", "vehicle", "train"], + "🚆": ["transportation", "vehicle"], + "🚇": ["transportation", "blue-square", "mrt", "underground", "tube"], + "🚊": ["transportation", "vehicle"], + "🚉": ["transportation", "vehicle", "public"], + "🛸": ["transportation", "vehicle", "ufo"], + "🚁": ["transportation", "vehicle", "fly"], + "🛩": ["flight", "transportation", "fly", "vehicle"], + "✈️": ["vehicle", "transportation", "flight", "fly"], + "🛫": ["airport", "flight", "landing"], + "🛬": ["airport", "flight", "boarding"], + "⛵": ["ship", "summer", "transportation", "water", "sailing"], + "🛥": ["ship"], + "🚤": ["ship", "transportation", "vehicle", "summer"], + "⛴": ["boat", "ship", "yacht"], + "🛳": ["yacht", "cruise", "ferry"], + "🚀": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"], + "🛰": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"], + "🛻": ["car"], + "🛼": [], + "💺": ["sit", "airplane", "transport", "bus", "flight", "fly"], + "🛶": ["boat", "paddle", "water", "ship"], + "⚓": ["ship", "ferry", "sea", "boat"], + "🚧": ["wip", "progress", "caution", "warning"], + "⛽": ["gas station", "petroleum"], + "🚏": ["transportation", "wait"], + "🚦": ["transportation", "driving"], + "🚥": ["transportation", "signal"], + "🏁": ["contest", "finishline", "race", "gokart"], + "🚢": ["transportation", "titanic", "deploy"], + "🎡": ["photo", "carnival", "londoneye"], + "🎢": ["carnival", "playground", "photo", "fun"], + "🎠": ["photo", "carnival"], + "🏗": ["wip", "working", "progress"], + "🌁": ["photo", "mountain"], + "🏭": ["building", "industry", "pollution", "smoke"], + "⛲": ["photo", "summer", "water", "fresh"], + "🎑": ["photo", "japan", "asia", "tsukimi"], + "⛰": ["photo", "nature", "environment"], + "🏔": ["photo", "nature", "environment", "winter", "cold"], + "🗻": ["photo", "mountain", "nature", "japanese"], + "🌋": ["photo", "nature", "disaster"], + "🗾": ["nation", "country", "japanese", "asia"], + "🏕": ["photo", "outdoors", "tent"], + "⛺": ["photo", "camping", "outdoors"], + "🏞": ["photo", "environment", "nature"], + "🛣": ["road", "cupertino", "interstate", "highway"], + "🛤": ["train", "transportation"], + "🌅": ["morning", "view", "vacation", "photo"], + "🌄": ["view", "vacation", "photo"], + "🏜": ["photo", "warm", "saharah"], + "🏖": ["weather", "summer", "sunny", "sand", "mojito"], + "🏝": ["photo", "tropical", "mojito"], + "🌇": ["photo", "good morning", "dawn"], + "🌆": ["photo", "evening", "sky", "buildings"], + "🏙": ["photo", "night life", "urban"], + "🌃": ["evening", "city", "downtown"], + "🌉": ["photo", "sanfrancisco"], + "🌌": ["photo", "space", "stars"], + "🌠": ["night", "photo"], + "🎇": ["stars", "night", "shine"], + "🎆": ["photo", "festival", "carnival", "congratulations"], + "🌈": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"], + "🏘": ["buildings", "photo"], + "🏰": ["building", "royalty", "history"], + "🏯": ["photo", "building"], + "🗼": ["photo", "japanese"], + "": ["photo", "japanese"], + "🏟": ["photo", "place", "sports", "concert", "venue"], + "🗽": ["american", "newyork"], + "🏠": ["building", "home"], + "🏡": ["home", "plant", "nature"], + "🏚": ["abandon", "evict", "broken", "building"], + "🏢": ["building", "bureau", "work"], + "🏬": ["building", "shopping", "mall"], + "🏣": ["building", "envelope", "communication"], + "🏤": ["building", "email"], + "🏥": ["building", "health", "surgery", "doctor"], + "🏦": ["building", "money", "sales", "cash", "business", "enterprise"], + "🏨": ["building", "accomodation", "checkin"], + "🏪": ["building", "shopping", "groceries"], + "🏫": ["building", "student", "education", "learn", "teach"], + "🏩": ["like", "affection", "dating"], + "💒": ["love", "like", "affection", "couple", "marriage", "bride", "groom"], + "🏛": ["art", "culture", "history"], + "⛪": ["building", "religion", "christ"], + "🕌": ["islam", "worship", "minaret"], + "🕍": ["judaism", "worship", "temple", "jewish"], + "🕋": ["mecca", "mosque", "islam"], + "⛩": ["temple", "japan", "kyoto"], + "🛕": ["temple"], + "🪨": [], + "🪵": [], + "🛖": [], + "🛝": [], + "🛞": [], + "🛟": [], + "⌚": ["time", "accessories"], + "📱": ["technology", "apple", "gadgets", "dial"], + "📲": ["iphone", "incoming"], + "💻": ["technology", "laptop", "screen", "display", "monitor"], + "⌨": ["technology", "computer", "type", "input", "text"], + "🖥": ["technology", "computing", "screen"], + "🖨": ["paper", "ink"], + "🖱": ["click"], + "🖲": ["technology", "trackpad"], + "🕹": ["game", "play"], + "🗜": ["tool"], + "💽": ["technology", "record", "data", "disk", "90s"], + "💾": ["oldschool", "technology", "save", "90s", "80s"], + "💿": ["technology", "dvd", "disk", "disc", "90s"], + "📀": ["cd", "disk", "disc"], + "📼": ["record", "video", "oldschool", "90s", "80s"], + "📷": ["gadgets", "photography"], + "📸": ["photography", "gadgets"], + "📹": ["film", "record"], + "🎥": ["film", "record"], + "📽": ["video", "tape", "record", "movie"], + "🎞": ["movie"], + "📞": ["technology", "communication", "dial"], + "☎️": ["technology", "communication", "dial", "telephone"], + "📟": ["bbcall", "oldschool", "90s"], + "📠": ["communication", "technology"], + "📺": ["technology", "program", "oldschool", "show", "television"], + "📻": ["communication", "music", "podcast", "program"], + "🎙": ["sing", "recording", "artist", "talkshow"], + "🎚": ["scale"], + "🎛": ["dial"], + "🧭": ["magnetic", "navigation", "orienteering"], + "⏱": ["time", "deadline"], + "⏲": ["alarm"], + "⏰": ["time", "wake"], + "🕰": ["time"], + "⏳": ["oldschool", "time", "countdown"], + "⌛": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"], + "📡": ["communication", "future", "radio", "space"], + "🔋": ["power", "energy", "sustain"], + "🪫": [], + "🔌": ["charger", "power"], + "💡": ["light", "electricity", "idea"], + "🔦": ["dark", "camping", "sight", "night"], + "🕯": ["fire", "wax"], + "🧯": ["quench"], + "🗑": ["bin", "trash", "rubbish", "garbage", "toss"], + "🛢": ["barrell"], + "💸": ["dollar", "bills", "payment", "sale"], + "💵": ["money", "sales", "bill", "currency"], + "💴": ["money", "sales", "japanese", "dollar", "currency"], + "💶": ["money", "sales", "dollar", "currency"], + "💷": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"], + "💰": ["dollar", "payment", "coins", "sale"], + "🪙": ["dollar", "payment", "coins", "sale"], + "💳": ["money", "sales", "dollar", "bill", "payment", "shopping"], + "🪫": [], + "💎": ["blue", "ruby", "diamond", "jewelry"], + "⚖": ["law", "fairness", "weight"], + "🧰": ["tools", "diy", "fix", "maintainer", "mechanic"], + "🔧": ["tools", "diy", "ikea", "fix", "maintainer"], + "🔨": ["tools", "build", "create"], + "⚒": ["tools", "build", "create"], + "🛠": ["tools", "build", "create"], + "⛏": ["tools", "dig"], + "🪓": ["tools"], + "🦯": ["tools"], + "🔩": ["handy", "tools", "fix"], + "⚙": ["cog"], + "🪃": ["tool"], + "🪚": ["tool"], + "🪛": ["tool"], + "🪝": ["tool"], + "🪜": ["tool"], + "🧱": ["bricks"], + "⛓": ["lock", "arrest"], + "🧲": ["attraction", "magnetic"], + "🔫": ["violence", "weapon", "pistol", "revolver"], + "💣": ["boom", "explode", "explosion", "terrorism"], + "🧨": ["dynamite", "boom", "explode", "explosion", "explosive"], + "🔪": ["knife", "blade", "cutlery", "kitchen", "weapon"], + "🗡": ["weapon"], + "⚔": ["weapon"], + "🛡": ["protection", "security"], + "🚬": ["kills", "tobacco", "cigarette", "joint", "smoke"], + "☠": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"], + "⚰": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"], + "⚱": ["dead", "die", "death", "rip", "ashes"], + "🏺": ["vase", "jar"], + "🔮": ["disco", "party", "magic", "circus", "fortune_teller"], + "📿": ["dhikr", "religious"], + "🧿": ["bead", "charm"], + "💈": ["hair", "salon", "style"], + "⚗": ["distilling", "science", "experiment", "chemistry"], + "🔭": ["stars", "space", "zoom", "science", "astronomy"], + "🔬": ["laboratory", "experiment", "zoomin", "science", "study"], + "🕳": ["embarrassing"], + "💊": ["health", "medicine", "doctor", "pharmacy", "drug"], + "💉": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"], + "🩸": ["health", "hospital", "medicine", "needle", "doctor", "nurse"], + "🩹": ["health", "hospital", "medicine", "needle", "doctor", "nurse"], + "🩺": ["health", "hospital", "medicine", "needle", "doctor", "nurse"], + "🪒": ["health"], + "🩻": [], + "🩼": [], + "🧬": ["biologist", "genetics", "life"], + "🧫": ["bacteria", "biology", "culture", "lab"], + "🧪": ["chemistry", "experiment", "lab", "science"], + "🌡": ["weather", "temperature", "hot", "cold"], + "🧹": ["cleaning", "sweeping", "witch"], + "🧺": ["laundry"], + "🧻": ["roll"], + "🏷": ["sale", "tag"], + "🔖": ["favorite", "label", "save"], + "🚽": ["restroom", "wc", "washroom", "bathroom", "potty"], + "🚿": ["clean", "water", "bathroom"], + "🛁": ["clean", "shower", "bathroom"], + "🧼": ["bar", "bathing", "cleaning", "lather"], + "🧽": ["absorbing", "cleaning", "porous"], + "🧴": ["moisturizer", "sunscreen"], + "🔑": ["lock", "door", "password"], + "🗝": ["lock", "door", "password"], + "🛋": ["read", "chill"], + "🪔": ["light", "oil"], + "🛌": ["bed", "rest"], + "🛏": ["sleep", "rest"], + "🚪": ["house", "entry", "exit"], + "🪑": ["house", "desk"], + "🛎": ["service"], + "🧸": ["plush", "stuffed"], + "🖼": ["photography"], + "🗺": ["location", "direction"], + "🛗": ["household"], + "🪞": ["household"], + "🪟": ["household"], + "🪠": ["household"], + "🪤": ["household"], + "🪣": ["household"], + "🪥": ["household"], + "🫧": [], + "⛱": ["weather", "summer"], + "🗿": ["rock", "easter island", "moai"], + "🛍": ["mall", "buy", "purchase"], + "🛒": ["trolley"], + "🎈": ["party", "celebration", "birthday", "circus"], + "🎏": ["fish", "japanese", "koinobori", "carp", "banner"], + "🎀": ["decoration", "pink", "girl", "bowtie"], + "🎁": ["present", "birthday", "christmas", "xmas"], + "🎊": ["festival", "party", "birthday", "circus"], + "🎉": ["party", "congratulations", "birthday", "magic", "circus", "celebration"], + "🎎": ["japanese", "toy", "kimono"], + "🎐": ["nature", "ding", "spring", "bell"], + "🎌": ["japanese", "nation", "country", "border"], + "🏮": ["light", "paper", "halloween", "spooky"], + "🧧": ["gift"], + "✉️": ["letter", "postal", "inbox", "communication"], + "📩": ["email", "communication"], + "📨": ["email", "inbox"], + "📧": ["communication", "inbox"], + "💌": ["email", "like", "affection", "envelope", "valentines"], + "📮": ["email", "letter", "envelope"], + "📪": ["email", "communication", "inbox"], + "📫": ["email", "inbox", "communication"], + "📬": ["email", "inbox", "communication"], + "📭": ["email", "inbox"], + "📦": ["mail", "gift", "cardboard", "box", "moving"], + "📯": ["instrument", "music"], + "📥": ["email", "documents"], + "📤": ["inbox", "email"], + "📜": ["documents", "ancient", "history", "paper"], + "📃": ["documents", "office", "paper"], + "📑": ["favorite", "save", "order", "tidy"], + "🧾": ["accounting", "expenses"], + "📊": ["graph", "presentation", "stats"], + "📈": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"], + "📉": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"], + "📄": ["documents", "office", "paper", "information"], + "📅": ["calendar", "schedule"], + "📆": ["schedule", "date", "planning"], + "🗓": ["date", "schedule", "planning"], + "📇": ["business", "stationery"], + "🗃": ["business", "stationery"], + "🗳": ["election", "vote"], + "🗄": ["filing", "organizing"], + "📋": ["stationery", "documents"], + "🗒": ["memo", "stationery"], + "📁": ["documents", "business", "office"], + "📂": ["documents", "load"], + "🗂": ["organizing", "business", "stationery"], + "🗞": ["press", "headline"], + "📰": ["press", "headline"], + "📓": ["stationery", "record", "notes", "paper", "study"], + "📕": ["read", "library", "knowledge", "textbook", "learn"], + "📗": ["read", "library", "knowledge", "study"], + "📘": ["read", "library", "knowledge", "learn", "study"], + "📙": ["read", "library", "knowledge", "textbook", "study"], + "📔": ["classroom", "notes", "record", "paper", "study"], + "📒": ["notes", "paper"], + "📚": ["literature", "library", "study"], + "📖": ["book", "read", "library", "knowledge", "literature", "learn", "study"], + "🧷": ["diaper"], + "🔗": ["rings", "url"], + "📎": ["documents", "stationery"], + "🖇": ["documents", "stationery"], + "✂️": ["stationery", "cut"], + "📐": ["stationery", "math", "architect", "sketch"], + "📏": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"], + "🧮": ["calculation"], + "📌": ["stationery", "mark", "here"], + "📍": ["stationery", "location", "map", "here"], + "🚩": ["mark", "milestone", "place"], + "🏳": ["losing", "loser", "lost", "surrender", "give up", "fail"], + "🏴": ["pirate"], + "🏳️🌈": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"], + "🏳️⚧️": ["flag", "transgender"], + "🔐": ["security", "privacy"], + "🔒": ["security", "password", "padlock"], + "🔓": ["privacy", "security"], + "🔏": ["security", "secret"], + "🖊": ["stationery", "writing", "write"], + "🖋": ["stationery", "writing", "write"], + "✒️": ["pen", "stationery", "writing", "write"], + "📝": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"], + "✏️": ["stationery", "write", "paper", "writing", "school", "study"], + "🖍": ["drawing", "creativity"], + "🖌": ["drawing", "creativity", "art"], + "🔍": ["search", "zoom", "find", "detective"], + "🔎": ["search", "zoom", "find", "detective"], + "🪦": [], + "🪧": [], + "💯": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"], + "🔢": ["numbers", "blue-square"], + "❤️": ["love", "like", "affection", "valentines"], + "🧡": ["love", "like", "affection", "valentines"], + "💛": ["love", "like", "affection", "valentines"], + "💚": ["love", "like", "affection", "valentines"], + "💙": ["love", "like", "affection", "valentines"], + "💜": ["love", "like", "affection", "valentines"], + "🤎": ["love", "like", "affection", "valentines"], + "🖤": ["love", "like", "affection", "valentines"], + "🤍": ["love", "like", "affection", "valentines"], + "💔": ["sad", "sorry", "break", "heart", "heartbreak"], + "❣": ["decoration", "love"], + "💕": ["love", "like", "affection", "valentines", "heart"], + "💞": ["love", "like", "affection", "valentines"], + "💓": ["love", "like", "affection", "valentines", "pink", "heart"], + "💗": ["like", "love", "affection", "valentines", "pink"], + "💖": ["love", "like", "affection", "valentines"], + "💘": ["love", "like", "heart", "affection", "valentines"], + "💝": ["love", "valentines"], + "💟": ["purple-square", "love", "like"], + "❤️🔥": [], + "❤️🩹": [], + "☮": ["hippie"], + "✝": ["christianity"], + "☪": ["islam"], + "🕉": ["hinduism", "buddhism", "sikhism", "jainism"], + "☸": ["hinduism", "buddhism", "sikhism", "jainism"], + "✡": ["judaism"], + "🔯": ["purple-square", "religion", "jewish", "hexagram"], + "🕎": ["hanukkah", "candles", "jewish"], + "☯": ["balance"], + "☦": ["suppedaneum", "religion"], + "🛐": ["religion", "church", "temple", "prayer"], + "⛎": ["sign", "purple-square", "constellation", "astrology"], + "♈": ["sign", "purple-square", "zodiac", "astrology"], + "♉": ["purple-square", "sign", "zodiac", "astrology"], + "♊": ["sign", "zodiac", "purple-square", "astrology"], + "♋": ["sign", "zodiac", "purple-square", "astrology"], + "♌": ["sign", "purple-square", "zodiac", "astrology"], + "♍": ["sign", "zodiac", "purple-square", "astrology"], + "♎": ["sign", "purple-square", "zodiac", "astrology"], + "♏": ["sign", "zodiac", "purple-square", "astrology", "scorpio"], + "♐": ["sign", "zodiac", "purple-square", "astrology"], + "♑": ["sign", "zodiac", "purple-square", "astrology"], + "♒": ["sign", "purple-square", "zodiac", "astrology"], + "♓": ["purple-square", "sign", "zodiac", "astrology"], + "🆔": ["purple-square", "words"], + "⚛": ["science", "physics", "chemistry"], + "⚧️": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"], + "🈳": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"], + "🈹": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"], + "☢": ["nuclear", "danger"], + "☣": ["danger"], + "📴": ["mute", "orange-square", "silence", "quiet"], + "📳": ["orange-square", "phone"], + "🈶": ["orange-square", "chinese", "have", "kanji", "ari"], + "🈚": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"], + "🈸": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"], + "🈺": ["japanese", "opening hours", "orange-square", "eigyo"], + "🈷️": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"], + "✴️": ["orange-square", "shape", "polygon"], + "🆚": ["words", "orange-square"], + "🉑": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"], + "💮": ["japanese", "spring"], + "🉐": ["chinese", "kanji", "obtain", "get", "circle"], + "㊙️": ["privacy", "chinese", "sshh", "kanji", "red-circle"], + "㊗️": ["chinese", "kanji", "japanese", "red-circle"], + "🈴": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"], + "🈵": ["full", "chinese", "japanese", "red-square", "kanji", "man"], + "🈲": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"], + "🅰️": ["red-square", "alphabet", "letter"], + "🅱️": ["red-square", "alphabet", "letter"], + "🆎": ["red-square", "alphabet"], + "🆑": ["alphabet", "words", "red-square"], + "🅾️": ["alphabet", "red-square", "letter"], + "🆘": ["help", "red-square", "words", "emergency", "911"], + "⛔": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"], + "📛": ["fire", "forbid"], + "🚫": ["forbid", "stop", "limit", "denied", "disallow", "circle"], + "❌": ["no", "delete", "remove", "cancel", "red"], + "⭕": ["circle", "round"], + "🛑": ["stop"], + "💢": ["angry", "mad"], + "♨️": ["bath", "warm", "relax"], + "🚷": ["rules", "crossing", "walking", "circle"], + "🚯": ["trash", "bin", "garbage", "circle"], + "🚳": ["cyclist", "prohibited", "circle"], + "🚱": ["drink", "faucet", "tap", "circle"], + "🔞": ["18", "drink", "pub", "night", "minor", "circle"], + "📵": ["iphone", "mute", "circle"], + "❗": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"], + "❕": ["surprise", "punctuation", "gray", "wow", "warning"], + "❓": ["doubt", "confused"], + "❔": ["doubts", "gray", "huh", "confused"], + "‼️": ["exclamation", "surprise"], + "⁉️": ["wat", "punctuation", "surprise"], + "🔅": ["sun", "afternoon", "warm", "summer"], + "🔆": ["sun", "light"], + "🔱": ["weapon", "spear"], + "⚜": ["decorative", "scout"], + "〽️": ["graph", "presentation", "stats", "business", "economics", "bad"], + "⚠️": ["exclamation", "wip", "alert", "error", "problem", "issue"], + "🚸": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"], + "🔰": ["badge", "shield"], + "♻️": ["arrow", "environment", "garbage", "trash"], + "🈯": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"], + "💹": ["green-square", "graph", "presentation", "stats"], + "❇️": ["stars", "green-square", "awesome", "good", "fireworks"], + "✳️": ["star", "sparkle", "green-square"], + "❎": ["x", "green-square", "no", "deny"], + "✅": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"], + "💠": ["jewel", "blue", "gem", "crystal", "fancy"], + "🌀": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"], + "➿": ["tape", "cassette"], + "🌐": ["earth", "international", "world", "internet", "interweb", "i18n"], + "Ⓜ️": ["alphabet", "blue-circle", "letter"], + "🏧": ["money", "sales", "cash", "blue-square", "payment", "bank"], + "🈂️": ["japanese", "blue-square", "katakana"], + "🛂": ["custom", "blue-square"], + "🛃": ["passport", "border", "blue-square"], + "🛄": ["blue-square", "airport", "transport"], + "🛅": ["blue-square", "travel"], + "♿": ["blue-square", "disabled", "a11y", "accessibility"], + "🚭": ["cigarette", "blue-square", "smell", "smoke"], + "🚾": ["toilet", "restroom", "blue-square"], + "🅿️": ["cars", "blue-square", "alphabet", "letter"], + "🚰": ["blue-square", "liquid", "restroom", "cleaning", "faucet"], + "🚹": ["toilet", "restroom", "wc", "blue-square", "gender", "male"], + "🚺": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"], + "🚼": ["orange-square", "child"], + "🚻": ["blue-square", "toilet", "refresh", "wc", "gender"], + "🚮": ["blue-square", "sign", "human", "info"], + "🎦": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"], + "📶": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"], + "🈁": ["blue-square", "here", "katakana", "japanese", "destination"], + "🆖": ["blue-square", "words", "shape", "icon"], + "🆗": ["good", "agree", "yes", "blue-square"], + "🆙": ["blue-square", "above", "high"], + "🆒": ["words", "blue-square"], + "🆕": ["blue-square", "words", "start"], + "🆓": ["blue-square", "words"], + "0️⃣": ["0", "numbers", "blue-square", "null"], + "1️⃣": ["blue-square", "numbers", "1"], + "2️⃣": ["numbers", "2", "prime", "blue-square"], + "3️⃣": ["3", "numbers", "prime", "blue-square"], + "4️⃣": ["4", "numbers", "blue-square"], + "5️⃣": ["5", "numbers", "blue-square", "prime"], + "6️⃣": ["6", "numbers", "blue-square"], + "7️⃣": ["7", "numbers", "blue-square", "prime"], + "8️⃣": ["8", "blue-square", "numbers"], + "9️⃣": ["blue-square", "numbers", "9"], + "🔟": ["numbers", "10", "blue-square"], + "*⃣": ["star", "keycap"], + "⏏️": ["blue-square"], + "▶️": ["blue-square", "right", "direction", "play"], + "⏸": ["pause", "blue-square"], + "⏭": ["forward", "next", "blue-square"], + "⏹": ["blue-square"], + "⏺": ["blue-square"], + "⏯": ["blue-square", "play", "pause"], + "⏮": ["backward"], + "⏩": ["blue-square", "play", "speed", "continue"], + "⏪": ["play", "blue-square"], + "🔀": ["blue-square", "shuffle", "music", "random"], + "🔁": ["loop", "record"], + "🔂": ["blue-square", "loop"], + "◀️": ["blue-square", "left", "direction"], + "🔼": ["blue-square", "triangle", "direction", "point", "forward", "top"], + "🔽": ["blue-square", "direction", "bottom"], + "⏫": ["blue-square", "direction", "top"], + "⏬": ["blue-square", "direction", "bottom"], + "➡️": ["blue-square", "next"], + "⬅️": ["blue-square", "previous", "back"], + "⬆️": ["blue-square", "continue", "top", "direction"], + "⬇️": ["blue-square", "direction", "bottom"], + "↗️": ["blue-square", "point", "direction", "diagonal", "northeast"], + "↘️": ["blue-square", "direction", "diagonal", "southeast"], + "↙️": ["blue-square", "direction", "diagonal", "southwest"], + "↖️": ["blue-square", "point", "direction", "diagonal", "northwest"], + "↕️": ["blue-square", "direction", "way", "vertical"], + "↔️": ["shape", "direction", "horizontal", "sideways"], + "🔄": ["blue-square", "sync", "cycle"], + "↪️": ["blue-square", "return", "rotate", "direction"], + "↩️": ["back", "return", "blue-square", "undo", "enter"], + "⤴️": ["blue-square", "direction", "top"], + "⤵️": ["blue-square", "direction", "bottom"], + "#️⃣": ["symbol", "blue-square", "twitter"], + "ℹ️": ["blue-square", "alphabet", "letter"], + "🔤": ["blue-square", "alphabet"], + "🔡": ["blue-square", "alphabet"], + "🔠": ["alphabet", "words", "blue-square"], + "🔣": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"], + "🎵": ["score", "tone", "sound"], + "🎶": ["music", "score"], + "〰️": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"], + "➰": ["scribble", "draw", "shape", "squiggle"], + "✔️": ["ok", "nike", "answer", "yes", "tick"], + "🔃": ["sync", "cycle", "round", "repeat"], + "➕": ["math", "calculation", "addition", "more", "increase"], + "➖": ["math", "calculation", "subtract", "less"], + "➗": ["divide", "math", "calculation"], + "✖️": ["math", "calculation"], + "🟰": [], + "♾": ["forever"], + "💲": ["money", "sales", "payment", "currency", "buck"], + "💱": ["money", "sales", "dollar", "travel"], + "©️": ["ip", "license", "circle", "law", "legal"], + "®️": ["alphabet", "circle"], + "™️": ["trademark", "brand", "law", "legal"], + "🔚": ["words", "arrow"], + "🔙": ["arrow", "words", "return"], + "🔛": ["arrow", "words"], + "🔝": ["words", "blue-square"], + "🔜": ["arrow", "words"], + "☑️": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"], + "🔘": ["input", "old", "music", "circle"], + "⚫": ["shape", "button", "round"], + "⚪": ["shape", "round"], + "🔴": ["shape", "error", "danger"], + "🟠": ["shape"], + "🟡": ["shape"], + "🟢": ["shape"], + "🔵": ["shape", "icon", "button"], + "🟣": ["shape"], + "🟤": ["shape"], + "🔸": ["shape", "jewel", "gem"], + "🔹": ["shape", "jewel", "gem"], + "🔶": ["shape", "jewel", "gem"], + "🔷": ["shape", "jewel", "gem"], + "🔺": ["shape", "direction", "up", "top"], + "▪️": ["shape", "icon"], + "▫️": ["shape", "icon"], + "⬛": ["shape", "icon", "button"], + "⬜": ["shape", "icon", "stone", "button"], + "🟥": ["shape"], + "🟧": ["shape"], + "🟨": ["shape"], + "🟩": ["shape"], + "🟦": ["shape"], + "🟪": ["shape"], + "🟫": ["shape"], + "🔻": ["shape", "direction", "bottom"], + "◼️": ["shape", "button", "icon"], + "◻️": ["shape", "stone", "icon"], + "◾": ["icon", "shape", "button"], + "◽": ["shape", "stone", "icon", "button"], + "🔲": ["shape", "input", "frame"], + "🔳": ["shape", "input"], + "🔈": ["sound", "volume", "silence", "broadcast"], + "🔉": ["volume", "speaker", "broadcast"], + "🔊": ["volume", "noise", "noisy", "speaker", "broadcast"], + "🔇": ["sound", "volume", "silence", "quiet"], + "📣": ["sound", "speaker", "volume"], + "📢": ["volume", "sound"], + "🔔": ["sound", "notification", "christmas", "xmas", "chime"], + "🔕": ["sound", "volume", "mute", "quiet", "silent"], + "🃏": ["poker", "cards", "game", "play", "magic"], + "🀄": ["game", "play", "chinese", "kanji"], + "♠️": ["poker", "cards", "suits", "magic"], + "♣️": ["poker", "cards", "magic", "suits"], + "♥️": ["poker", "cards", "magic", "suits"], + "♦️": ["poker", "cards", "magic", "suits"], + "🎴": ["game", "sunset", "red"], + "💭": ["bubble", "cloud", "speech", "thinking", "dream"], + "🗯": ["caption", "speech", "thinking", "mad"], + "💬": ["bubble", "words", "message", "talk", "chatting"], + "🗨": ["words", "message", "talk", "chatting"], + "🕐": ["time", "late", "early", "schedule"], + "🕑": ["time", "late", "early", "schedule"], + "🕒": ["time", "late", "early", "schedule"], + "🕓": ["time", "late", "early", "schedule"], + "🕔": ["time", "late", "early", "schedule"], + "🕕": ["time", "late", "early", "schedule", "dawn", "dusk"], + "🕖": ["time", "late", "early", "schedule"], + "🕗": ["time", "late", "early", "schedule"], + "🕘": ["time", "late", "early", "schedule"], + "🕙": ["time", "late", "early", "schedule"], + "🕚": ["time", "late", "early", "schedule"], + "🕛": ["time", "noon", "midnight", "midday", "late", "early", "schedule"], + "🕜": ["time", "late", "early", "schedule"], + "🕝": ["time", "late", "early", "schedule"], + "🕞": ["time", "late", "early", "schedule"], + "🕟": ["time", "late", "early", "schedule"], + "🕠": ["time", "late", "early", "schedule"], + "🕡": ["time", "late", "early", "schedule"], + "🕢": ["time", "late", "early", "schedule"], + "🕣": ["time", "late", "early", "schedule"], + "🕤": ["time", "late", "early", "schedule"], + "🕥": ["time", "late", "early", "schedule"], + "🕦": ["time", "late", "early", "schedule"], + "🕧": ["time", "late", "early", "schedule"], + "🇦🇫": ["af", "flag", "nation", "country", "banner"], + "🇦🇽": ["Åland", "islands", "flag", "nation", "country", "banner"], + "🇦🇱": ["al", "flag", "nation", "country", "banner"], + "🇩🇿": ["dz", "flag", "nation", "country", "banner"], + "🇦🇸": ["american", "ws", "flag", "nation", "country", "banner"], + "🇦🇩": ["ad", "flag", "nation", "country", "banner"], + "🇦🇴": ["ao", "flag", "nation", "country", "banner"], + "🇦🇮": ["ai", "flag", "nation", "country", "banner"], + "🇦🇶": ["aq", "flag", "nation", "country", "banner"], + "🇦🇬": ["antigua", "barbuda", "flag", "nation", "country", "banner"], + "🇦🇷": ["ar", "flag", "nation", "country", "banner"], + "🇦🇲": ["am", "flag", "nation", "country", "banner"], + "🇦🇼": ["aw", "flag", "nation", "country", "banner"], + "🇦🇨": ["flag", "nation", "country", "banner"], + "🇦🇺": ["au", "flag", "nation", "country", "banner"], + "🇦🇹": ["at", "flag", "nation", "country", "banner"], + "🇦🇿": ["az", "flag", "nation", "country", "banner"], + "🇧🇸": ["bs", "flag", "nation", "country", "banner"], + "🇧🇭": ["bh", "flag", "nation", "country", "banner"], + "🇧🇩": ["bd", "flag", "nation", "country", "banner"], + "🇧🇧": ["bb", "flag", "nation", "country", "banner"], + "🇧🇾": ["by", "flag", "nation", "country", "banner"], + "🇧🇪": ["be", "flag", "nation", "country", "banner"], + "🇧🇿": ["bz", "flag", "nation", "country", "banner"], + "🇧🇯": ["bj", "flag", "nation", "country", "banner"], + "🇧🇲": ["bm", "flag", "nation", "country", "banner"], + "🇧🇹": ["bt", "flag", "nation", "country", "banner"], + "🇧🇴": ["bo", "flag", "nation", "country", "banner"], + "🇧🇶": ["bonaire", "flag", "nation", "country", "banner"], + "🇧🇦": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"], + "🇧🇼": ["bw", "flag", "nation", "country", "banner"], + "🇧🇷": ["br", "flag", "nation", "country", "banner"], + "🇮🇴": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"], + "🇻🇬": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"], + "🇧🇳": ["bn", "darussalam", "flag", "nation", "country", "banner"], + "🇧🇬": ["bg", "flag", "nation", "country", "banner"], + "🇧🇫": ["burkina", "faso", "flag", "nation", "country", "banner"], + "🇧🇮": ["bi", "flag", "nation", "country", "banner"], + "🇨🇻": ["cabo", "verde", "flag", "nation", "country", "banner"], + "🇰🇭": ["kh", "flag", "nation", "country", "banner"], + "🇨🇲": ["cm", "flag", "nation", "country", "banner"], + "🇨🇦": ["ca", "flag", "nation", "country", "banner"], + "🇮🇨": ["canary", "islands", "flag", "nation", "country", "banner"], + "🇰🇾": ["cayman", "islands", "flag", "nation", "country", "banner"], + "🇨🇫": ["central", "african", "republic", "flag", "nation", "country", "banner"], + "🇹🇩": ["td", "flag", "nation", "country", "banner"], + "🇨🇱": ["flag", "nation", "country", "banner"], + "🇨🇳": ["china", "chinese", "prc", "flag", "country", "nation", "banner"], + "🇨🇽": ["christmas", "island", "flag", "nation", "country", "banner"], + "🇨🇨": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"], + "🇨🇴": ["co", "flag", "nation", "country", "banner"], + "🇰🇲": ["km", "flag", "nation", "country", "banner"], + "🇨🇬": ["congo", "flag", "nation", "country", "banner"], + "🇨🇩": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"], + "🇨🇰": ["cook", "islands", "flag", "nation", "country", "banner"], + "🇨🇷": ["costa", "rica", "flag", "nation", "country", "banner"], + "🇭🇷": ["hr", "flag", "nation", "country", "banner"], + "🇨🇺": ["cu", "flag", "nation", "country", "banner"], + "🇨🇼": ["curaçao", "flag", "nation", "country", "banner"], + "🇨🇾": ["cy", "flag", "nation", "country", "banner"], + "🇨🇿": ["cz", "flag", "nation", "country", "banner"], + "🇩🇰": ["dk", "flag", "nation", "country", "banner"], + "🇩🇯": ["dj", "flag", "nation", "country", "banner"], + "🇩🇲": ["dm", "flag", "nation", "country", "banner"], + "🇩🇴": ["dominican", "republic", "flag", "nation", "country", "banner"], + "🇪🇨": ["ec", "flag", "nation", "country", "banner"], + "🇪🇬": ["eg", "flag", "nation", "country", "banner"], + "🇸🇻": ["el", "salvador", "flag", "nation", "country", "banner"], + "🇬🇶": ["equatorial", "gn", "flag", "nation", "country", "banner"], + "🇪🇷": ["er", "flag", "nation", "country", "banner"], + "🇪🇪": ["ee", "flag", "nation", "country", "banner"], + "🇪🇹": ["et", "flag", "nation", "country", "banner"], + "🇪🇺": ["european", "union", "flag", "banner"], + "🇫🇰": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"], + "🇫🇴": ["faroe", "islands", "flag", "nation", "country", "banner"], + "🇫🇯": ["fj", "flag", "nation", "country", "banner"], + "🇫🇮": ["fi", "flag", "nation", "country", "banner"], + "🇫🇷": ["banner", "flag", "nation", "france", "french", "country"], + "🇬🇫": ["french", "guiana", "flag", "nation", "country", "banner"], + "🇵🇫": ["french", "polynesia", "flag", "nation", "country", "banner"], + "🇹🇫": ["french", "southern", "territories", "flag", "nation", "country", "banner"], + "🇬🇦": ["ga", "flag", "nation", "country", "banner"], + "🇬🇲": ["gm", "flag", "nation", "country", "banner"], + "🇬🇪": ["ge", "flag", "nation", "country", "banner"], + "🇩🇪": ["german", "nation", "flag", "country", "banner"], + "🇬🇭": ["gh", "flag", "nation", "country", "banner"], + "🇬🇮": ["gi", "flag", "nation", "country", "banner"], + "🇬🇷": ["gr", "flag", "nation", "country", "banner"], + "🇬🇱": ["gl", "flag", "nation", "country", "banner"], + "🇬🇩": ["gd", "flag", "nation", "country", "banner"], + "🇬🇵": ["gp", "flag", "nation", "country", "banner"], + "🇬🇺": ["gu", "flag", "nation", "country", "banner"], + "🇬🇹": ["gt", "flag", "nation", "country", "banner"], + "🇬🇬": ["gg", "flag", "nation", "country", "banner"], + "🇬🇳": ["gn", "flag", "nation", "country", "banner"], + "🇬🇼": ["gw", "bissau", "flag", "nation", "country", "banner"], + "🇬🇾": ["gy", "flag", "nation", "country", "banner"], + "🇭🇹": ["ht", "flag", "nation", "country", "banner"], + "🇭🇳": ["hn", "flag", "nation", "country", "banner"], + "🇭🇰": ["hong", "kong", "flag", "nation", "country", "banner"], + "🇭🇺": ["hu", "flag", "nation", "country", "banner"], + "🇮🇸": ["is", "flag", "nation", "country", "banner"], + "🇮🇳": ["in", "flag", "nation", "country", "banner"], + "🇮🇩": ["flag", "nation", "country", "banner"], + "🇮🇷": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"], + "🇮🇶": ["iq", "flag", "nation", "country", "banner"], + "🇮🇪": ["ie", "flag", "nation", "country", "banner"], + "🇮🇲": ["isle", "man", "flag", "nation", "country", "banner"], + "🇮🇱": ["il", "flag", "nation", "country", "banner"], + "🇮🇹": ["italy", "flag", "nation", "country", "banner"], + "🇨🇮": ["ivory", "coast", "flag", "nation", "country", "banner"], + "🇯🇲": ["jm", "flag", "nation", "country", "banner"], + "🇯🇵": ["japanese", "nation", "flag", "country", "banner"], + "🇯🇪": ["je", "flag", "nation", "country", "banner"], + "🇯🇴": ["jo", "flag", "nation", "country", "banner"], + "🇰🇿": ["kz", "flag", "nation", "country", "banner"], + "🇰🇪": ["ke", "flag", "nation", "country", "banner"], + "🇰🇮": ["ki", "flag", "nation", "country", "banner"], + "🇽🇰": ["xk", "flag", "nation", "country", "banner"], + "🇰🇼": ["kw", "flag", "nation", "country", "banner"], + "🇰🇬": ["kg", "flag", "nation", "country", "banner"], + "🇱🇦": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"], + "🇱🇻": ["lv", "flag", "nation", "country", "banner"], + "🇱🇧": ["lb", "flag", "nation", "country", "banner"], + "🇱🇸": ["ls", "flag", "nation", "country", "banner"], + "🇱🇷": ["lr", "flag", "nation", "country", "banner"], + "🇱🇾": ["ly", "flag", "nation", "country", "banner"], + "🇱🇮": ["li", "flag", "nation", "country", "banner"], + "🇱🇹": ["lt", "flag", "nation", "country", "banner"], + "🇱🇺": ["lu", "flag", "nation", "country", "banner"], + "🇲🇴": ["macao", "flag", "nation", "country", "banner"], + "🇲🇰": ["macedonia, ", "flag", "nation", "country", "banner"], + "🇲🇬": ["mg", "flag", "nation", "country", "banner"], + "🇲🇼": ["mw", "flag", "nation", "country", "banner"], + "🇲🇾": ["my", "flag", "nation", "country", "banner"], + "🇲🇻": ["mv", "flag", "nation", "country", "banner"], + "🇲🇱": ["ml", "flag", "nation", "country", "banner"], + "🇲🇹": ["mt", "flag", "nation", "country", "banner"], + "🇲🇭": ["marshall", "islands", "flag", "nation", "country", "banner"], + "🇲🇶": ["mq", "flag", "nation", "country", "banner"], + "🇲🇷": ["mr", "flag", "nation", "country", "banner"], + "🇲🇺": ["mu", "flag", "nation", "country", "banner"], + "🇾🇹": ["yt", "flag", "nation", "country", "banner"], + "🇲🇽": ["mx", "flag", "nation", "country", "banner"], + "🇫🇲": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"], + "🇲🇩": ["moldova, ", "republic", "flag", "nation", "country", "banner"], + "🇲🇨": ["mc", "flag", "nation", "country", "banner"], + "🇲🇳": ["mn", "flag", "nation", "country", "banner"], + "🇲🇪": ["me", "flag", "nation", "country", "banner"], + "🇲🇸": ["ms", "flag", "nation", "country", "banner"], + "🇲🇦": ["ma", "flag", "nation", "country", "banner"], + "🇲🇿": ["mz", "flag", "nation", "country", "banner"], + "🇲🇲": ["mm", "flag", "nation", "country", "banner"], + "🇳🇦": ["na", "flag", "nation", "country", "banner"], + "🇳🇷": ["nr", "flag", "nation", "country", "banner"], + "🇳🇵": ["np", "flag", "nation", "country", "banner"], + "🇳🇱": ["nl", "flag", "nation", "country", "banner"], + "🇳🇨": ["new", "caledonia", "flag", "nation", "country", "banner"], + "🇳🇿": ["new", "zealand", "flag", "nation", "country", "banner"], + "🇳🇮": ["ni", "flag", "nation", "country", "banner"], + "🇳🇪": ["ne", "flag", "nation", "country", "banner"], + "🇳🇬": ["flag", "nation", "country", "banner"], + "🇳🇺": ["nu", "flag", "nation", "country", "banner"], + "🇳🇫": ["norfolk", "island", "flag", "nation", "country", "banner"], + "🇲🇵": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"], + "🇰🇵": ["north", "korea", "nation", "flag", "country", "banner"], + "🇳🇴": ["no", "flag", "nation", "country", "banner"], + "🇴🇲": ["om_symbol", "flag", "nation", "country", "banner"], + "🇵🇰": ["pk", "flag", "nation", "country", "banner"], + "🇵🇼": ["pw", "flag", "nation", "country", "banner"], + "🇵🇸": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"], + "🇵🇦": ["pa", "flag", "nation", "country", "banner"], + "🇵🇬": ["papua", "new", "guinea", "flag", "nation", "country", "banner"], + "🇵🇾": ["py", "flag", "nation", "country", "banner"], + "🇵🇪": ["pe", "flag", "nation", "country", "banner"], + "🇵🇭": ["ph", "flag", "nation", "country", "banner"], + "🇵🇳": ["pitcairn", "flag", "nation", "country", "banner"], + "🇵🇱": ["pl", "flag", "nation", "country", "banner"], + "🇵🇹": ["pt", "flag", "nation", "country", "banner"], + "🇵🇷": ["puerto", "rico", "flag", "nation", "country", "banner"], + "🇶🇦": ["qa", "flag", "nation", "country", "banner"], + "🇷🇪": ["réunion", "flag", "nation", "country", "banner"], + "🇷🇴": ["ro", "flag", "nation", "country", "banner"], + "🇷🇺": ["russian", "federation", "flag", "nation", "country", "banner"], + "🇷🇼": ["rw", "flag", "nation", "country", "banner"], + "🇧🇱": ["saint", "barthélemy", "flag", "nation", "country", "banner"], + "🇸🇭": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"], + "🇰🇳": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"], + "🇱🇨": ["saint", "lucia", "flag", "nation", "country", "banner"], + "🇵🇲": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"], + "🇻🇨": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"], + "🇼🇸": ["ws", "flag", "nation", "country", "banner"], + "🇸🇲": ["san", "marino", "flag", "nation", "country", "banner"], + "🇸🇹": ["sao", "tome", "principe", "flag", "nation", "country", "banner"], + "🇸🇦": ["flag", "nation", "country", "banner"], + "🇸🇳": ["sn", "flag", "nation", "country", "banner"], + "🇷🇸": ["rs", "flag", "nation", "country", "banner"], + "🇸🇨": ["sc", "flag", "nation", "country", "banner"], + "🇸🇱": ["sierra", "leone", "flag", "nation", "country", "banner"], + "🇸🇬": ["sg", "flag", "nation", "country", "banner"], + "🇸🇽": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"], + "🇸🇰": ["sk", "flag", "nation", "country", "banner"], + "🇸🇮": ["si", "flag", "nation", "country", "banner"], + "🇸🇧": ["solomon", "islands", "flag", "nation", "country", "banner"], + "🇸🇴": ["so", "flag", "nation", "country", "banner"], + "🇿🇦": ["south", "africa", "flag", "nation", "country", "banner"], + "🇬🇸": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"], + "🇰🇷": ["south", "korea", "nation", "flag", "country", "banner"], + "🇸🇸": ["south", "sd", "flag", "nation", "country", "banner"], + "🇪🇸": ["spain", "flag", "nation", "country", "banner"], + "🇱🇰": ["sri", "lanka", "flag", "nation", "country", "banner"], + "🇸🇩": ["sd", "flag", "nation", "country", "banner"], + "🇸🇷": ["sr", "flag", "nation", "country", "banner"], + "🇸🇿": ["sz", "flag", "nation", "country", "banner"], + "🇸🇪": ["se", "flag", "nation", "country", "banner"], + "🇨🇭": ["ch", "flag", "nation", "country", "banner"], + "🇸🇾": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"], + "🇹🇼": ["tw", "flag", "nation", "country", "banner"], + "🇹🇯": ["tj", "flag", "nation", "country", "banner"], + "🇹🇿": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"], + "🇹🇭": ["th", "flag", "nation", "country", "banner"], + "🇹🇱": ["timor", "leste", "flag", "nation", "country", "banner"], + "🇹🇬": ["tg", "flag", "nation", "country", "banner"], + "🇹🇰": ["tk", "flag", "nation", "country", "banner"], + "🇹🇴": ["to", "flag", "nation", "country", "banner"], + "🇹🇹": ["trinidad", "tobago", "flag", "nation", "country", "banner"], + "🇹🇦": ["flag", "nation", "country", "banner"], + "🇹🇳": ["tn", "flag", "nation", "country", "banner"], + "🇹🇷": ["turkey", "flag", "nation", "country", "banner"], + "🇹🇲": ["flag", "nation", "country", "banner"], + "🇹🇨": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"], + "🇹🇻": ["flag", "nation", "country", "banner"], + "🇺🇬": ["ug", "flag", "nation", "country", "banner"], + "🇺🇦": ["ua", "flag", "nation", "country", "banner"], + "🇦🇪": ["united", "arab", "emirates", "flag", "nation", "country", "banner"], + "🇬🇧": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"], + "🏴": ["flag", "english"], + "🏴": ["flag", "scottish"], + "🏴": ["flag", "welsh"], + "🇺🇸": ["united", "states", "america", "flag", "nation", "country", "banner"], + "🇻🇮": ["virgin", "islands", "us", "flag", "nation", "country", "banner"], + "🇺🇾": ["uy", "flag", "nation", "country", "banner"], + "🇺🇿": ["uz", "flag", "nation", "country", "banner"], + "🇻🇺": ["vu", "flag", "nation", "country", "banner"], + "🇻🇦": ["vatican", "city", "flag", "nation", "country", "banner"], + "🇻🇪": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"], + "🇻🇳": ["viet", "nam", "flag", "nation", "country", "banner"], + "🇼🇫": ["wallis", "futuna", "flag", "nation", "country", "banner"], + "🇪🇭": ["western", "sahara", "flag", "nation", "country", "banner"], + "🇾🇪": ["ye", "flag", "nation", "country", "banner"], + "🇿🇲": ["zm", "flag", "nation", "country", "banner"], + "🇿🇼": ["zw", "flag", "nation", "country", "banner"], + "🇺🇳": ["un", "flag", "banner"], + "🏴☠️": ["skull", "crossbones", "flag", "banner"] +} diff --git a/packages/frontend/src/widgets/WidgetActivity.calendar.vue b/packages/frontend/src/widgets/WidgetActivity.calendar.vue index 84f6af1c13..110f1d32eb 100644 --- a/packages/frontend/src/widgets/WidgetActivity.calendar.vue +++ b/packages/frontend/src/widgets/WidgetActivity.calendar.vue @@ -1,25 +1,31 @@ <template> <svg viewBox="0 0 21 7"> - <rect v-for="record in activity" class="day" + <rect + v-for="record in activity" class="day" width="1" height="1" :x="record.x" :y="record.date.weekday" rx="1" ry="1" - fill="transparent"> + fill="transparent" + > <title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title> </rect> - <rect v-for="record in activity" class="day" + <rect + v-for="record in activity" class="day" :width="record.v" :height="record.v" :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" rx="1" ry="1" :fill="record.color" - style="pointer-events: none;"/> - <rect class="today" + style="pointer-events: none;" + /> + <rect + class="today" width="1" height="1" :x="activity[0].x" :y="activity[0].date.weekday" rx="1" ry="1" fill="none" stroke-width="0.1" - stroke="#f73520"/> + stroke="#f73520" + /> </svg> </template> diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue index b61e419f94..cc4df65dd2 100644 --- a/packages/frontend/src/widgets/WidgetActivity.chart.vue +++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue @@ -1,26 +1,30 @@ <template> -<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown"> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" :class="$style.root" @mousedown.prevent="onMousedown"> <polyline :points="pointsNote" fill="none" stroke-width="1" - stroke="#41ddde"/> + stroke="#41ddde" + /> <polyline :points="pointsReply" fill="none" stroke-width="1" - stroke="#f7796c"/> + stroke="#f7796c" + /> <polyline :points="pointsRenote" fill="none" stroke-width="1" - stroke="#a1de41"/> + stroke="#a1de41" + /> <polyline :points="pointsTotal" fill="none" stroke-width="1" stroke="#555" - stroke-dasharray="2 2"/> + stroke-dasharray="2 2" + /> </svg> </template> @@ -81,8 +85,8 @@ function render() { } </script> -<style lang="scss" scoped> -svg { +<style lang="scss" module> +.root { display: block; padding: 16px; width: 100%; diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index e7f8819abd..892b24f69d 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" data-cy-mkw-activity class="mkw-activity"> +<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent" data-cy-mkw-activity class="mkw-activity"> <template #icon><i class="ti ti-chart-line"></i></template> <template #header>{{ i18n.ts._widgets.activity }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template> @@ -16,7 +16,7 @@ <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import XCalendar from './WidgetActivity.calendar.vue'; import XChart from './WidgetActivity.chart.vue'; import { GetFormResultType } from '@/scripts/form'; @@ -45,11 +45,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue index 37326ee981..797dd9c09f 100644 --- a/packages/frontend/src/widgets/WidgetAichan.vue +++ b/packages/frontend/src/widgets/WidgetAichan.vue @@ -1,12 +1,12 @@ <template> -<MkContainer :naked="widgetProps.transparent" :show-header="false" data-cy-mkw-aichan class="mkw-aichan"> - <iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe> +<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-cy-mkw-aichan class="mkw-aichan"> + <iframe ref="live2d" :class="$style.root" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe> </MkContainer> </template> <script lang="ts" setup> import { onMounted, onUnmounted, shallowRef } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; const name = 'ai'; @@ -20,11 +20,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -64,8 +61,8 @@ defineExpose<WidgetComponentExpose>({ }); </script> -<style lang="scss" scoped> -.dedjhjmo { +<style lang="scss" module> +.root { width: 100%; height: 350px; border: none; diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue index 947dbe5e77..d6c94cd56a 100644 --- a/packages/frontend/src/widgets/WidgetAiscript.vue +++ b/packages/frontend/src/widgets/WidgetAiscript.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-aiscript class="mkw-aiscript"> +<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-aiscript class="mkw-aiscript"> <template #icon><i class="ti ti-terminal-2"></i></template> <template #header>{{ i18n.ts._widgets.aiscript }}</template> @@ -16,7 +16,7 @@ <script lang="ts" setup> import { ref } from 'vue'; import { Interpreter, Parser, utils } from '@syuilo/aiscript'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import MkContainer from '@/components/MkContainer.vue'; @@ -41,11 +41,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index 455a6e6ea5..3b67972e40 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscriptApp"> +<MkContainer :showHeader="widgetProps.showHeader" class="mkw-aiscriptApp"> <template #header>App</template> <div :class="$style.root"> <MkAsUi v-if="root" :component="root" :components="components" size="small"/> @@ -10,7 +10,7 @@ <script lang="ts" setup> import { onMounted, Ref, ref, watch } from 'vue'; import { Interpreter, Parser } from '@syuilo/aiscript'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; @@ -35,12 +35,9 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); - +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); + const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, props, diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue index 9eee9680db..bcb380f849 100644 --- a/packages/frontend/src/widgets/WidgetButton.vue +++ b/packages/frontend/src/widgets/WidgetButton.vue @@ -8,7 +8,7 @@ <script lang="ts" setup> import { Interpreter, Parser } from '@syuilo/aiscript'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; @@ -35,11 +35,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -101,8 +98,3 @@ defineExpose<WidgetComponentExpose>({ id: props.widget ? props.widget.id : null, }); </script> - -<style lang="scss" scoped> -.mkw-button { -} -</style> diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index 58d0732263..447525837c 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -34,7 +34,7 @@ <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import { i18n } from '@/i18n'; import { useInterval } from '@/scripts/use-interval'; @@ -50,11 +50,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue index 981788a3c5..b7be2e8c83 100644 --- a/packages/frontend/src/widgets/WidgetClicker.vue +++ b/packages/frontend/src/widgets/WidgetClicker.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-clicker"> +<MkContainer :showHeader="widgetProps.showHeader" class="mkw-clicker"> <template #icon><i class="ti ti-cookie"></i></template> <template #header>Clicker</template> <MkClickerGame/> @@ -7,7 +7,7 @@ </template> <script lang="ts" setup> -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import MkClickerGame from '@/components/MkClickerGame.vue'; @@ -23,12 +23,9 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); - +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); + const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, props, diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index ebd73cb9f5..aee5026db4 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -1,25 +1,31 @@ <template> -<MkContainer :naked="widgetProps.transparent" :show-header="false" data-cy-mkw-clock class="mkw-clock"> - <div class="vubelbmv" :class="widgetProps.size"> - <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div> +<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-cy-mkw-clock> + <div + :class="[$style.root, { + [$style.small]: widgetProps.size === 'small', + [$style.medium]: widgetProps.size === 'medium', + [$style.large]: widgetProps.size === 'large', + }]" + > + <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace" :class="[$style.label, $style.a]">{{ tzAbbrev }}</div> <MkAnalogClock - class="clock" + :class="$style.clock" :thickness="widgetProps.thickness" :offset="tzOffset" :graduations="widgetProps.graduations" - :fade-graduations="widgetProps.fadeGraduations" + :fadeGraduations="widgetProps.fadeGraduations" :twentyfour="widgetProps.twentyFour" - :s-animation="widgetProps.sAnimation" + :sAnimation="widgetProps.sAnimation" /> - <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/> - <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div> + <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" :class="[$style.label, $style.c]" class="_monospace" :showS="false" :offset="tzOffset"/> + <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace" :class="[$style.label, $style.d]">{{ tzOffsetLabel }}</div> </div> </MkContainer> </template> <script lang="ts" setup> import { } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import MkAnalogClock from '@/components/MkAnalogClock.vue'; @@ -114,11 +120,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -143,39 +146,10 @@ defineExpose<WidgetComponentExpose>({ }); </script> -<style lang="scss" scoped> -.vubelbmv { +<style lang="scss" module> +.root { position: relative; - > .label { - position: absolute; - opacity: 0.7; - - &.a { - top: 14px; - left: 14px; - } - - &.b { - top: 14px; - right: 14px; - } - - &.c { - bottom: 14px; - left: 14px; - } - - &.d { - bottom: 14px; - right: 14px; - } - } - - > .clock { - margin: auto; - } - &.small { padding: 12px; @@ -200,4 +174,33 @@ defineExpose<WidgetComponentExpose>({ } } } + +.label { + position: absolute; + opacity: 0.7; + + &.a { + top: 14px; + left: 14px; + } + + &.b { + top: 14px; + right: 14px; + } + + &.c { + bottom: 14px; + left: 14px; + } + + &.d { + bottom: 14px; + right: 14px; + } +} + +.clock { + margin: auto; +} </style> diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue index cdd9c3a401..6148177d9a 100644 --- a/packages/frontend/src/widgets/WidgetDigitalClock.vue +++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue @@ -2,14 +2,14 @@ <div data-cy-mkw-digitalClock class="_monospace" :class="[$style.root, { _panel: !widgetProps.transparent }]" :style="{ fontSize: `${widgetProps.fontSize}em` }"> <div v-if="widgetProps.showLabel" :class="$style.label">{{ tzAbbrev }}</div> <div> - <MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/> + <MkDigitalClock :showMs="widgetProps.showMs" :offset="tzOffset"/> </div> <div v-if="widgetProps.showLabel" :class="$style.label">{{ tzOffsetLabel }}</div> </div> </template> <script lang="ts" setup> -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import { timezones } from '@/scripts/timezones'; import MkDigitalClock from '@/components/MkDigitalClock.vue'; @@ -49,11 +49,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 2033b074e0..951c4aaa6d 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" data-cy-mkw-federation class="mkw-federation"> +<MkContainer :showHeader="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" data-cy-mkw-federation class="mkw-federation"> <template #icon><i class="ti ti-whirl"></i></template> <template #header>{{ i18n.ts._widgets.federation }}</template> @@ -21,7 +21,7 @@ <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; @@ -42,11 +42,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue index b157807655..f8b811e6ba 100644 --- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue +++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud"> +<MkContainer :naked="widgetProps.transparent" :showHeader="false" class="mkw-instance-cloud"> <div class=""> <MkTagCloud v-if="activeInstances"> <li v-for="instance in activeInstances" :key="instance.id"> @@ -14,7 +14,7 @@ <script lang="ts" setup> import { } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import MkTagCloud from '@/components/MkTagCloud.vue'; @@ -33,11 +33,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -75,7 +72,3 @@ defineExpose<WidgetComponentExpose>({ id: props.widget ? props.widget.id : null, }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue index d702fd2cb0..c77b98f8f4 100644 --- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue +++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue @@ -15,7 +15,7 @@ </template> <script lang="ts" setup> -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import { host } from '@/config'; import { instance } from '@/instance'; @@ -27,11 +27,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 84043cf13f..3c8ffdb55a 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -47,9 +47,9 @@ <script lang="ts" setup> import { onUnmounted, reactive } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import number from '@/filters/number'; import * as sound from '@/scripts/sound'; import { deepClone } from '@/scripts/clone'; @@ -69,11 +69,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -81,7 +78,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const connection = stream.useChannel('queueStats'); +const connection = useStream().useChannel('queueStats'); const current = reactive({ inbox: { activeSincePrevTick: 0, diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index 959cf776ad..78d27a31b9 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-memo class="mkw-memo"> +<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-memo class="mkw-memo"> <template #icon><i class="ti ti-note"></i></template> <template #header>{{ i18n.ts._widgets.memo }}</template> @@ -12,7 +12,7 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import { defaultStore } from '@/store'; @@ -33,11 +33,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index 661f68b278..a24aa9b2e9 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -1,18 +1,18 @@ <template> -<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" data-cy-mkw-notifications class="mkw-notifications"> +<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" data-cy-mkw-notifications class="mkw-notifications"> <template #icon><i class="ti ti-bell"></i></template> <template #header>{{ i18n.ts.notifications }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> <div> - <XNotifications :include-types="widgetProps.includingTypes"/> + <XNotifications :includeTypes="widgetProps.includingTypes"/> </div> </MkContainer> </template> <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import XNotifications from '@/components/MkNotifications.vue'; @@ -39,12 +39,9 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); - +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); + const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, props, diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index 44e073545d..c920c3ca53 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -1,14 +1,16 @@ <template> -<div data-cy-mkw-onlineUsers class="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>{{ number(onlineUsersCount) }}</b></template> - </I18n> +<div data-cy-mkw-onlineUsers :class="[$style.root, { _panel: !widgetProps.transparent, [$style.pad]: !widgetProps.transparent }]"> + <span :class="$style.text"> + <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" textTag="span"> + <template #n><b style="color: #41b781;">{{ number(onlineUsersCount) }}</b></template> + </I18n> + </span> </div> </template> <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; @@ -26,11 +28,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -58,22 +57,16 @@ defineExpose<WidgetComponentExpose>({ }); </script> -<style lang="scss" scoped> -.mkw-onlineUsers { +<style lang="scss" module> +.root { text-align: center; &.pad { padding: 16px 0; } +} - > .text { - ::v-deep(b) { - color: #41b781; - } - - ::v-deep(span) { - opacity: 0.7; - } - } +.text { + color: var(--fgTransparentWeak); } </style> diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index 716bbb4274..5c6a8cbf83 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" data-cy-mkw-photos class="mkw-photos"> +<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" data-cy-mkw-photos class="mkw-photos"> <template #icon><i class="ti ti-camera"></i></template> <template #header>{{ i18n.ts._widgets.photos }}</template> @@ -18,9 +18,9 @@ <script lang="ts" setup> import { onUnmounted, ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { getStaticImageUrl } from '@/scripts/media-proxy'; import * as os from '@/os'; import MkContainer from '@/components/MkContainer.vue'; @@ -42,11 +42,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, @@ -54,7 +51,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const connection = stream.useChannel('main'); +const connection = useStream().useChannel('main'); const images = ref([]); const fetching = ref(true); diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue index 7a96b00217..bc63f02821 100644 --- a/packages/frontend/src/widgets/WidgetPostForm.vue +++ b/packages/frontend/src/widgets/WidgetPostForm.vue @@ -4,7 +4,7 @@ <script lang="ts" setup> import { } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkPostForm from '@/components/MkPostForm.vue'; @@ -15,11 +15,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue index 819663a366..72e229ef8f 100644 --- a/packages/frontend/src/widgets/WidgetProfile.vue +++ b/packages/frontend/src/widgets/WidgetProfile.vue @@ -17,7 +17,7 @@ </template> <script lang="ts" setup> -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import { $i } from '@/account'; import { userPage } from '@/filters/user'; @@ -29,11 +29,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index 18fa2e2c22..1be882c66d 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-rss class="mkw-rss"> +<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-rss class="mkw-rss"> <template #icon><i class="ti ti-rss"></i></template> <template #header>RSS</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure"><i class="ti ti-settings"></i></button></template> @@ -19,7 +19,7 @@ <script lang="ts" setup> import { ref, watch, computed } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import { url as base } from '@/config'; @@ -49,11 +49,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index b0408f0d7f..6b346c0598 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker"> +<MkContainer :naked="widgetProps.transparent" :showHeader="widgetProps.showHeader" class="mkw-rss-ticker"> <template #icon><i class="ti ti-rss"></i></template> <template #header>RSS</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure"><i class="ti ti-settings"></i></button></template> @@ -12,7 +12,7 @@ <Transition :name="$style.change" mode="default" appear> <MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> <span v-for="item in items" :key="item.link" :class="$style.item"> - <a :class="$style.link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> + <a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> </span> </MarqueeText> </Transition> @@ -23,7 +23,7 @@ <script lang="ts" setup> import { ref, watch, computed } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import MarqueeText from '@/components/MkMarquee.vue'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; @@ -73,11 +73,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 915e7aaaf4..d4ede57926 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -13,7 +13,7 @@ <script lang="ts" setup> import { onMounted, ref, shallowRef } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; @@ -35,11 +35,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 71ee75f6cb..3d497c2e23 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" data-cy-mkw-timeline class="mkw-timeline"> +<MkContainer :showHeader="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" data-cy-mkw-timeline class="mkw-timeline"> <template #icon> <i v-if="widgetProps.src === 'home'" class="ti ti-home"></i> <i v-else-if="widgetProps.src === 'local'" class="ti ti-planet"></i> @@ -30,7 +30,7 @@ <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import MkContainer from '@/components/MkContainer.vue'; @@ -71,11 +71,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 01450a7ab5..36f908d5ea 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-trends class="mkw-trends"> +<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-trends class="mkw-trends"> <template #icon><i class="ti ti-hash"></i></template> <template #header>{{ i18n.ts._widgets.trends }}</template> @@ -20,7 +20,7 @@ <script lang="ts" setup> import { ref } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; @@ -40,11 +40,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue index 22162d2b2c..f1af71adda 100644 --- a/packages/frontend/src/widgets/WidgetUnixClock.vue +++ b/packages/frontend/src/widgets/WidgetUnixClock.vue @@ -12,7 +12,7 @@ <script lang="ts" setup> import { onUnmounted, ref, watch } from 'vue'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; const name = 'unixClock'; @@ -39,11 +39,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index b8811d2fed..4380fdb62f 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-userList"> +<MkContainer :showHeader="widgetProps.showHeader" class="mkw-userList"> <template #icon><i class="ti ti-users"></i></template> <template #header>{{ list ? list.name : i18n.ts._widgets.userList }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure()"><i class="ti ti-settings"></i></button></template> @@ -19,7 +19,7 @@ </template> <script lang="ts" setup> -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os'; @@ -43,11 +43,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index 357d0ab78b..e019ff540b 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent"> +<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent"> <template #icon><i class="ti ti-server"></i></template> <template #header>{{ i18n.ts._widgets.serverMetric }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template> @@ -25,7 +25,7 @@ import XDisk from './disk.vue'; import MkContainer from '@/components/MkContainer.vue'; import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; -import { stream } from '@/stream'; +import { useStream } from '@/stream'; import { i18n } from '@/i18n'; const name = 'serverMetric'; @@ -75,7 +75,7 @@ const toggleView = () => { save(); }; -const connection = stream.useChannel('serverStats'); +const connection = useStream().useChannel('serverStats'); onUnmounted(() => { connection.dispose(); }); diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue index 868dbc0484..398815a6ae 100644 --- a/packages/frontend/src/widgets/server-metric/pie.vue +++ b/packages/frontend/src/widgets/server-metric/pie.vue @@ -1,11 +1,12 @@ <template> -<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none"> +<svg :class="$style.root" viewBox="0 0 1 1" preserveAspectRatio="none"> <circle :r="r" cx="50%" cy="50%" fill="none" stroke-width="0.1" stroke="rgba(0, 0, 0, 0.05)" + :class="$style.circle" /> <circle :r="r" @@ -16,7 +17,7 @@ stroke-width="0.1" :stroke="color" /> - <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> + <text x="50%" y="50%" dy="0.05" text-anchor="middle" :class="$style.text">{{ (value * 100).toFixed(0) }}%</text> </svg> </template> @@ -33,20 +34,20 @@ const color = $computed(() => `hsl(${180 - (props.value * 180)}, 80%, 70%)`); const strokeDashoffset = $computed(() => (1 - props.value) * (Math.PI * (r * 2))); </script> -<style lang="scss" scoped> -.hsalcinq { +<style lang="scss" module> +.root { display: block; height: 100%; +} - > circle { - transform-origin: center; - transform: rotate(-90deg); - transition: stroke-dashoffset 0.5s ease; - } +.circle { + transform-origin: center; + transform: rotate(-90deg); + transition: stroke-dashoffset 0.5s ease; +} - > text { - font-size: 0.15px; - fill: currentColor; - } +.text { + font-size: 0.15px; + fill: currentColor; } </style> diff --git a/packages/frontend/src/workers/draw-blurhash.ts b/packages/frontend/src/workers/draw-blurhash.ts new file mode 100644 index 0000000000..5f2168a44a --- /dev/null +++ b/packages/frontend/src/workers/draw-blurhash.ts @@ -0,0 +1,15 @@ +import { render } from 'buraha'; + +onmessage = (event) => { + // console.log(event.data); + if (!('id' in event.data && typeof event.data.id === 'string')) { + return; + } + if (!('hash' in event.data && typeof event.data.hash === 'string')) { + return; + } + const work = new OffscreenCanvas(event.data.width ?? 64, event.data.height ?? 64); + render(event.data.hash, work); + const bitmap = work.transferToImageBitmap(); + postMessage({ id: event.data.id, bitmap }); +}; diff --git a/packages/frontend/src/workers/test-webgl2.ts b/packages/frontend/src/workers/test-webgl2.ts new file mode 100644 index 0000000000..4769524d9c --- /dev/null +++ b/packages/frontend/src/workers/test-webgl2.ts @@ -0,0 +1,7 @@ +const canvas = new OffscreenCanvas(1, 1); +const gl = canvas.getContext('webgl2'); +if (gl) { + postMessage({ result: true }); +} else { + postMessage({ result: false }); +} diff --git a/packages/frontend/src/workers/tsconfig.json b/packages/frontend/src/workers/tsconfig.json new file mode 100644 index 0000000000..8ee8930465 --- /dev/null +++ b/packages/frontend/src/workers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "lib": ["esnext", "webworker"], + } +} diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index fad0dd0177..531dd0b488 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -6,7 +6,9 @@ import { type UserConfig, defineConfig } from 'vite'; import ReactivityTransform from '@vue-macros/reactivity-transform/vite'; import locales from '../../locales'; +import generateDTS from '../../locales/generateDTS'; import meta from '../../package.json'; +import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name'; import pluginJson5 from './vite.json5'; const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; @@ -53,6 +55,7 @@ export function getConfig(): UserConfig { reactivityTransform: true, }), ReactivityTransform(), + pluginUnwindCssModuleClassName(), pluginJson5(), ...process.env.NODE_ENV === 'production' ? [ @@ -64,6 +67,10 @@ export function getConfig(): UserConfig { }), ] : [], + { + name: 'locale:generateDTS', + buildStart: generateDTS, + }, ], resolve: { @@ -117,13 +124,15 @@ export function getConfig(): UserConfig { manifest: 'manifest.json', rollupOptions: { input: { - app: './src/init.ts', + app: './src/_boot_.ts', }, output: { manualChunks: { vue: ['vue'], photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'], }, + chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', + assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]', }, }, cssCodeSplit: true, @@ -139,6 +148,10 @@ export function getConfig(): UserConfig { }, }, + worker: { + format: 'es', + }, + test: { environment: 'happy-dom', deps: { diff --git a/packages/shared/.eslintrc.js b/packages/shared/.eslintrc.js index 7c979a93dc..a53ad17894 100644 --- a/packages/shared/.eslintrc.js +++ b/packages/shared/.eslintrc.js @@ -49,7 +49,7 @@ module.exports = { 'no-multi-spaces': ['error'], 'no-var': ['error'], 'prefer-arrow-callback': ['error'], - 'no-throw-literal': ['warn'], + 'no-throw-literal': ['error'], 'no-param-reassign': ['warn'], 'no-constant-condition': ['warn'], 'no-empty-pattern': ['warn'], |