diff options
| author | piuvas <mail@piuvas.net> | 2025-06-03 10:56:10 -0300 |
|---|---|---|
| committer | piuvas <mail@piuvas.net> | 2025-06-03 10:56:10 -0300 |
| commit | 1120ad19ae16969e552d895c72ee802f47d26c25 (patch) | |
| tree | aa4a13d4cf5c508a215d2faca56123eb44ac21aa | |
| parent | check for whitespace in instance mutes. (diff) | |
| parent | merge: allow fragments in AP ID URLs - fixes polls (!1076) (diff) | |
| download | sharkey-1120ad19ae16969e552d895c72ee802f47d26c25.tar.gz sharkey-1120ad19ae16969e552d895c72ee802f47d26c25.tar.bz2 sharkey-1120ad19ae16969e552d895c72ee802f47d26c25.zip | |
merge develop and fix conflicts.
166 files changed, 2930 insertions, 1076 deletions
diff --git a/.config/ci.yml b/.config/ci.yml index 4a6d21e1d5..5fcf78b737 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -115,8 +115,14 @@ db: user: postgres pass: ci - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: @@ -297,6 +303,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy #mediaProxy: https://example.com/proxy diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 356d583611..97263da68f 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -57,8 +57,14 @@ db: user: postgres pass: postgres - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: @@ -260,6 +266,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy #mediaProxy: https://example.com/proxy diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 68679f64ed..3aaa56e333 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -118,8 +118,14 @@ db: user: example-misskey-user pass: example-misskey-pass - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: @@ -351,6 +357,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy # * Deliver a common cache between instances diff --git a/.config/example.yml b/.config/example.yml index 9cb1e656c1..8cac42c050 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -121,8 +121,14 @@ db: user: sharkey pass: example-misskey-pass - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: @@ -354,6 +360,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy # * Deliver a common cache between instances diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 6d904e87b9..1f8192101b 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -199,6 +199,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy #mediaProxy: https://example.com/proxy diff --git a/.gitignore b/.gitignore index b07d195a3f..ea761882da 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ vite.config.local-dev.ts.timestamp-* # Sharkey /packages/megalodon/lib + +# TypeScript +.tsbuildinfo +*.tsbuildinfo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8a58fadab..ef08d5275f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -690,7 +690,7 @@ seems to do a decent job) * re-generate locales (`pnpm run build-assets`) and commit * build the frontend: `rm -rf built/; NODE_ENV=development pnpm --filter=frontend --filter=frontend-embed --filter=frontend-shared build` (the `development` tells it to keep some of the original filenames in the built files) * make sure there aren't any new `ti-*` classes (Tabler Icons), and replace them with appropriate `ph-*` ones (Phosphor Icons) in [`vite.replaceicons.ts`](packages/frontend/vite.replaceIcons.ts). - * This command should show you want to change: `grep -ohrP '(?<=["'\'']ti )(ti-(?!fw)[\w\-]+)' --exclude \*.map -- built/ | sort -u`. + * This command should show you want to change: `grep -ohrP '(?<=["'\''](ti )?)(ti-(?!fw)[\w\-]+)' --exclude \*.map -- built/ | sort -u`. * NOTE: `ti-fw` is a special class that's defined by Misskey, leave it alone. * After every change, re-build the frontend and check again, until there are no more `ti-*` classes in the built files. * Commit! diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 6fe7f32cc4..4435a4fda8 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "lib": ["dom", "es5"], "target": "es5", - "types": ["cypress", "node"] + "types": ["cypress", "node"], + "incremental": true }, "include": ["./**/*.ts"] } diff --git a/locales/index.d.ts b/locales/index.d.ts index f809613e7a..cee973a0a2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12493,6 +12493,14 @@ export interface Locale extends ILocale { */ "centerDescription": string; /** + * Unix Time + */ + "unixtime": string; + /** + * Displays a timestamp in the viewer's current timezone. + */ + "unixtimeDescription": string; + /** * Code (Inline) */ "inlineCode": string; @@ -13070,6 +13078,26 @@ export interface Locale extends ILocale { */ "popularUsersLocal": ParameterizedString<"name">; /** + * Polls trending on {name} + */ + "pollsOnLocal": ParameterizedString<"name">; + /** + * Polls trending on the global network + */ + "pollsOnRemote": string; + /** + * Polls that have ended recently + */ + "pollsExpired": string; + /** + * Trending polls are disabled on this instance. + */ + "trendingPollsDisabled": string; + /** + * Please log in to view trending polls. + */ + "trendingPollsDisabledLogIn": string; + /** * Silenced */ "silenced": string; @@ -13141,6 +13169,54 @@ export interface Locale extends ILocale { * Written by {user} */ "writtenBy": ParameterizedString<"user">; + /** + * Following (Pub) + */ + "followingPub": string; + /** + * Followers (Sub) + */ + "followersSub": string; + /** + * Well-known resources + */ + "wellKnownResources": string; + /** + * Last posted: {at} + */ + "lastPosted": ParameterizedString<"at">; + /** + * NSFW + */ + "nsfw": string; + /** + * Raw + */ + "raw": string; + /** + * CW + */ + "cw": string; + /** + * Media Silenced + */ + "mediaSilenced": string; + /** + * Bubble + */ + "bubble": string; + /** + * Verified + */ + "verified": string; + /** + * Not Verified + */ + "notVerified": string; + /** + * Hibernated + */ + "hibernated": string; } declare const locales: { [lang: string]: Locale; diff --git a/package.json b/package.json index ffd07c5950..dfa7afda02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sharkey", - "version": "2025.4.2-rc", + "version": "2025.5.0-dev", "codename": "shonk", "repository": { "type": "git", diff --git a/packages/backend/migration/1747938628395-add-missing-indexes.js b/packages/backend/migration/1747938628395-add-missing-indexes.js index 745b39c855..0229a6c898 100644 --- a/packages/backend/migration/1747938628395-add-missing-indexes.js +++ b/packages/backend/migration/1747938628395-add-missing-indexes.js @@ -4,19 +4,35 @@ */ export class AddMissingIndexes1747938628395 { - name = 'AddMissingIndexes1747938628395' + name = 'AddMissingIndexes1747938628395' - async up(queryRunner) { - await queryRunner.query(`CREATE INDEX "IDX_58699f75b9cf904f5f007909cb" ON "user_profile" ("birthday") `); - await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `); - await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `); - } + async up(queryRunner) { + // Some instances have duplicate list entries + await queryRunner.query(` + DELETE FROM "user_list_membership" + WHERE "id" NOT IN ( + SELECT MIN("id") + FROM "user_list_membership" + GROUP BY "userId", "userListId" + )`); - async down(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`); - await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`); - await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`); - await queryRunner.query(`DROP INDEX "public"."IDX_58699f75b9cf904f5f007909cb"`); - } + // Some instances already have these indexes, for an unknown reason + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_e4f3094c43f2d665e6030b0337"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_cddcaf418dc4d392ecfcca842a"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_021015e6683570ae9f6b0c62be"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_58699f75b9cf904f5f007909cb"`); + + // Now the actual migration + await queryRunner.query(`CREATE INDEX "IDX_58699f75b9cf904f5f007909cb" ON "user_profile" ("birthday") `); + await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`); + await queryRunner.query(`DROP INDEX "public"."IDX_58699f75b9cf904f5f007909cb"`); + } } diff --git a/packages/backend/migration/1748104955717-index_IDX_instance_host_key.js b/packages/backend/migration/1748104955717-index_IDX_instance_host_key.js new file mode 100644 index 0000000000..139eae740f --- /dev/null +++ b/packages/backend/migration/1748104955717-index_IDX_instance_host_key.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class IndexIDXInstanceHostKey1748104955717 { + name = 'IndexIDXInstanceHostKey1748104955717' + + async up(queryRunner) { + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_instance_host_key"`); + } +} diff --git a/packages/backend/migration/1748105111513-add_instance_block_columns.js b/packages/backend/migration/1748105111513-add_instance_block_columns.js new file mode 100644 index 0000000000..6e3d78d5e8 --- /dev/null +++ b/packages/backend/migration/1748105111513-add_instance_block_columns.js @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {{ blockedHosts: string[], silencedHosts: string[], mediaSilencedHosts: string[], federationHosts: string[], bubbleInstances: string[] }} Meta + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AddInstanceBlockColumns1748105111513 { + name = 'AddInstanceBlockColumns1748105111513' + + async up(queryRunner) { + // Schema migration + await queryRunner.query(`ALTER TABLE "instance" ADD "isBlocked" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isBlocked" IS 'True if this instance is blocked from federation.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isAllowListed" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isAllowListed" IS 'True if this instance is allow-listed.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isBubbled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isBubbled" IS 'True if this instance is part of the local bubble.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isSilenced" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isSilenced" IS 'True if this instance is silenced.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isMediaSilenced" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isMediaSilenced" IS 'True if this instance is media-silenced.'`); + + // Data migration + /** @type {Meta[]} */ + const metas = await queryRunner.query(`SELECT "blockedHosts", "silencedHosts", "mediaSilencedHosts", "federationHosts", "bubbleInstances" FROM "meta"`); + if (metas.length > 0) { + /** @type {Meta} */ + const meta = metas[0]; + + // Blocked hosts + if (meta.blockedHosts.length > 0) { + const patterns = buildPatterns(meta.blockedHosts); + await queryRunner.query(`UPDATE "instance" SET "isBlocked" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Silenced hosts + if (meta.silencedHosts.length > 0) { + const patterns = buildPatterns(meta.silencedHosts); + await queryRunner.query(`UPDATE "instance" SET "isSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Media silenced hosts + if (meta.mediaSilencedHosts.length > 0) { + const patterns = buildPatterns(meta.mediaSilencedHosts); + await queryRunner.query(`UPDATE "instance" SET "isMediaSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Allow-listed hosts + if (meta.federationHosts.length > 0) { + const patterns = buildPatterns(meta.federationHosts); + await queryRunner.query(`UPDATE "instance" SET "isAllowListed" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Bubbled hosts + if (meta.bubbleInstances.length > 0) { + const patterns = buildPatterns(meta.bubbleInstances); + await queryRunner.query(`UPDATE "instance" SET "isBubbled" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + } + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isMediaSilenced"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isSilenced"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBubbled"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isAllowListed"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBlocked"`); + } +} + +/** + * @param {string[]} input + * @returns {string[]} + */ +function buildPatterns(input) { + return input.map(i => i.toLowerCase().split('').reverse().join('') + '.%'); +} diff --git a/packages/backend/migration/1748128176881-add_instance_foreign_keys.js b/packages/backend/migration/1748128176881-add_instance_foreign_keys.js new file mode 100644 index 0000000000..2c2383c50f --- /dev/null +++ b/packages/backend/migration/1748128176881-add_instance_foreign_keys.js @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AddInstanceForeignKeys1748128176881 { + name = 'AddInstanceForeignKeys1748128176881' + + async up(queryRunner) { + // Fix-up: Some older instances have users without a matching instance entry + await queryRunner.query(` + INSERT INTO "instance" ("id", "host", "firstRetrievedAt") + SELECT + MIN("id"), + "host", + COALESCE(MIN("lastFetchedAt"), CURRENT_TIMESTAMP) + FROM "user" + WHERE + "host" IS NOT NULL AND + NOT EXISTS (select 1 from "instance" where "instance"."host" = "user"."host") + GROUP BY "host" + `); + + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_user_host" FOREIGN KEY ("host") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_userHost" FOREIGN KEY ("userHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_replyUserHost" FOREIGN KEY ("replyUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_renoteUserHost" FOREIGN KEY ("renoteUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_renoteUserHost"`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_replyUserHost"`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_userHost"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_host"`); + } +} diff --git a/packages/backend/migration/1748137683887-add_instance_foreign_keys_to_following.js b/packages/backend/migration/1748137683887-add_instance_foreign_keys_to_following.js new file mode 100644 index 0000000000..8f4a977ff5 --- /dev/null +++ b/packages/backend/migration/1748137683887-add_instance_foreign_keys_to_following.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AddInstanceForeignKeysToFollowing1748137683887 { + name = 'AddInstanceForeignKeysToFollowing1748137683887' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followerHost" FOREIGN KEY ("followerHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followeeHost" FOREIGN KEY ("followeeHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followeeHost"`); + await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followerHost"`); + } +} diff --git a/packages/backend/migration/1748191631151-analyze_instance-user-note-following.js b/packages/backend/migration/1748191631151-analyze_instance-user-note-following.js new file mode 100644 index 0000000000..f03a60980b --- /dev/null +++ b/packages/backend/migration/1748191631151-analyze_instance-user-note-following.js @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AnalyzeInstanceUserNoteFollowing1748191631151 { + name = 'AnalyzeInstanceUserNoteFollowing1748191631151' + + async up(queryRunner) { + // Refresh statistics for tables impacted by new indexes. + // This helps the query planner to efficiently use them without waiting for the next full vacuum. + await queryRunner.query(`ANALYZE "instance", "user", "following", "note"`); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index b9cb0002ab..bad6990ba5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,6 +10,9 @@ "start": "node ./built/boot/entry.js", "start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js", + "migrate:revert": "pnpm typeorm migration:revert -d ormconfig.js", + "migrate:generate": "pnpm typeorm migration:generate -d ormconfig.js", + "migrate:create": "pnpm typeorm migration:create", "revert": "pnpm typeorm migration:revert -d ormconfig.js", "check:connect": "node ./scripts/check_connect.js", "build": "swc src -d built -D --strip-leading-paths", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index e02f09bbe1..c2e7efd456 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -9,6 +9,7 @@ import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; import { globSync } from 'glob'; import ipaddr from 'ipaddr.js'; +import Logger from './logger.js'; import type * as Sentry from '@sentry/node'; import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; @@ -40,6 +41,7 @@ type Source = { db?: string; user?: string; pass?: string; + slowQueryThreshold?: number; disableCache?: boolean; extra?: { [x: string]: string }; }; @@ -111,6 +113,7 @@ type Source = { deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; + mediaDirectory?: string; mediaProxy?: string; proxyRemoteFiles?: boolean; videoThumbnailGenerator?: string; @@ -154,6 +157,8 @@ type Source = { } }; +const configLogger = new Logger('config'); + export type PrivateNetworkSource = string | { network?: string, ports?: number[] }; export type PrivateNetwork = { @@ -191,7 +196,7 @@ export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefine } } - console.warn('[config] Skipping invalid entry in allowedPrivateNetworks: ', e); + configLogger.warn('Skipping invalid entry in allowedPrivateNetworks: ', e); return null; }) .filter(p => p != null); @@ -221,6 +226,7 @@ export type Config = { db: string; user: string; pass: string; + slowQueryThreshold?: number; disableCache?: boolean; extra?: { [x: string]: string }; }; @@ -297,6 +303,7 @@ export type Config = { frontendManifestExists: boolean; frontendEmbedEntry: string; frontendEmbedManifestExists: boolean; + mediaDirectory: string; mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; @@ -346,7 +353,7 @@ const _dirname = dirname(_filename); /** * Path of configuration directory */ -const dir = `${_dirname}/../../../.config`; +const dir = process.env.MISSKEY_CONFIG_DIR ?? `${_dirname}/../../../.config`; /** * Path of configuration file @@ -373,11 +380,14 @@ export function loadConfig(): Config { if (configFiles.length === 0 && !process.env['MK_WARNED_ABOUT_CONFIG']) { - console.log('No config files loaded, check if this is intentional'); + configLogger.warn('No config files loaded, check if this is intentional'); process.env['MK_WARNED_ABOUT_CONFIG'] = '1'; } - const config = configFiles.map(path => fs.readFileSync(path, 'utf-8')) + const config = configFiles.map(path => { + configLogger.info(`Reading configuration from ${path}`); + return fs.readFileSync(path, 'utf-8'); + }) .map(contents => yaml.load(contents) as Source) .reduce( (acc: Source, cur: Source) => Object.assign(acc, cur), @@ -403,6 +413,10 @@ export function loadConfig(): Config { const internalMediaProxy = `${scheme}://${host}/proxy`; const redis = convertRedisOptions(config.redis, host); + // nullish => 300 (default) + // 0 => undefined (disabled) + const slowQueryThreshold = (config.db.slowQueryThreshold ?? 300) || undefined; + return { version, publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, @@ -421,7 +435,7 @@ export function loadConfig(): Config { apiUrl: `${scheme}://${host}/api`, authUrl: `${scheme}://${host}/auth`, driveUrl: `${scheme}://${host}/files`, - db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass }, + db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass, slowQueryThreshold }, dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, fulltextSearch: config.fulltextSearch, @@ -463,6 +477,7 @@ export function loadConfig(): Config { signToActivityPubGet: config.signToActivityPubGet ?? true, attachLdSignatureForRelays: config.attachLdSignatureForRelays ?? true, checkActivityPubGetSignature: config.checkActivityPubGetSignature, + mediaDirectory: config.mediaDirectory ?? resolve(_dirname, '../../../files'), mediaProxy: externalMediaProxy ?? internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, videoThumbnailGenerator: config.videoThumbnailGenerator ? @@ -493,6 +508,10 @@ export function loadConfig(): Config { } function tryCreateUrl(url: string) { + if (!url) { + throw new Error('Failed to load: no "url" property found in config. Please check the value of "MISSKEY_CONFIG_DIR" and "MISSKEY_CONFIG_YML", and verify that all configuration files are correct.'); + } + try { return new URL(url); } catch (e) { @@ -624,7 +643,7 @@ function applyEnvOverrides(config: Source) { // these are all the settings that can be overridden _apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications', 'websocketCompression']]); - _apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'disableCache']]); + _apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'slowQueryThreshold', 'disableCache']]); _apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]); _apply_top([ ['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'], @@ -638,7 +657,7 @@ function applyEnvOverrides(config: Source) { _apply_top(['sentryForFrontend', 'vueIntegration', 'tracingOptions', 'timeout']); _apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']); _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); - _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); + _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]); diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 82c447baaa..73125f36d7 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -159,6 +159,14 @@ export class DriveService { // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); + if (type && type.startsWith('video/')) { + try { + await this.videoProcessingService.webOptimizeVideo(path, type); + } catch (err) { + this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err }); + } + } + if (this.meta.useObjectStorage) { //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index af2723e99d..f9cf41e854 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -136,10 +136,10 @@ export class FanoutTimelineEndpointService { const parentFilter = filter; filter = (note) => { if (!ps.ignoreAuthorFromInstanceBlock) { - if (this.utilityService.isBlockedHost(this.meta.blockedHosts, note.userHost)) return false; + if (note.userInstance?.isBlocked) return false; } - if (note.userId !== note.renoteUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.renoteUserHost)) return false; - if (note.userId !== note.replyUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.replyUserHost)) return false; + if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false; + if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false; return parentFilter(note); }; @@ -194,7 +194,10 @@ export class FanoutTimelineEndpointService { .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('note.channel', 'channel') + .leftJoinAndSelect('note.userInstance', 'userInstance') + .leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance') + .leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance'); const notes = (await query.getMany()).filter(noteFilter); diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 3f7ed99348..34df10f0ff 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -5,23 +5,24 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import { QueryFailedError } from 'typeorm'; -import type { InstancesRepository } from '@/models/_.js'; +import type { InstancesRepository, MiMeta } from '@/models/_.js'; import type { MiInstance } from '@/models/Instance.js'; -import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { Serialized } from '@/types.js'; +import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js'; @Injectable() export class FederatedInstanceService implements OnApplicationShutdown { - public federatedInstanceCache: RedisKVCache<MiInstance | null>; + private readonly federatedInstanceCache: MemoryKVCache<MiInstance | null>; constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -29,67 +30,46 @@ export class FederatedInstanceService implements OnApplicationShutdown { private utilityService: UtilityService, private idService: IdService, ) { - this.federatedInstanceCache = new RedisKVCache<MiInstance | null>(this.redisClient, 'federatedInstance', { - lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60 * 3, // 3m - fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => { - const parsed = JSON.parse(value); - if (parsed == null) return null; - return { - ...parsed, - firstRetrievedAt: new Date(parsed.firstRetrievedAt), - latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, - infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, - notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null, - }; - }, - }); + this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m + this.redisForSub.on('message', this.onMessage); } @bindThis public async fetchOrRegister(host: string): Promise<MiInstance> { host = this.utilityService.toPuny(host); - const cached = await this.federatedInstanceCache.get(host); + const cached = this.federatedInstanceCache.get(host); if (cached) return cached; - const index = await this.instancesRepository.findOneBy({ host }); - + let index = await this.instancesRepository.findOneBy({ host }); if (index == null) { - let i; - try { - i = await this.instancesRepository.insertOne({ + await this.instancesRepository.createQueryBuilder('instance') + .insert() + .values({ id: this.idService.gen(), host, firstRetrievedAt: new Date(), - }); - } catch (e: unknown) { - if (e instanceof QueryFailedError) { - if (isDuplicateKeyValueError(e)) { - i = await this.instancesRepository.findOneBy({ host }); - } - } + isBlocked: this.utilityService.isBlockedHost(host), + isSilenced: this.utilityService.isSilencedHost(host), + isMediaSilenced: this.utilityService.isMediaSilencedHost(host), + isAllowListed: this.utilityService.isAllowListedHost(host), + isBubbled: this.utilityService.isBubbledHost(host), + }) + .orIgnore() + .execute(); - if (i == null) { - throw e; - } - } - - this.federatedInstanceCache.set(host, i); - return i; - } else { - this.federatedInstanceCache.set(host, index); - return index; + index = await this.instancesRepository.findOneByOrFail({ host }); } + + this.federatedInstanceCache.set(host, index); + return index; } @bindThis public async fetch(host: string): Promise<MiInstance | null> { host = this.utilityService.toPuny(host); - const cached = await this.federatedInstanceCache.get(host); + const cached = this.federatedInstanceCache.get(host); if (cached !== undefined) return cached; const index = await this.instancesRepository.findOneBy({ host }); @@ -117,8 +97,35 @@ export class FederatedInstanceService implements OnApplicationShutdown { this.federatedInstanceCache.set(result.host, result); } + private syncCache(before: Serialized<MiMeta | undefined>, after: Serialized<MiMeta>): void { + const changed = + diffArraysSimple(before?.blockedHosts, after.blockedHosts) || + diffArraysSimple(before?.silencedHosts, after.silencedHosts) || + diffArraysSimple(before?.mediaSilencedHosts, after.mediaSilencedHosts) || + diffArraysSimple(before?.federationHosts, after.federationHosts) || + diffArraysSimple(before?.bubbleInstances, after.bubbleInstances); + + if (changed) { + // We have to clear the whole thing, otherwise subdomains won't be synced. + this.federatedInstanceCache.clear(); + } + } + + @bindThis + private async onMessage(_: string, data: string): Promise<void> { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + if (type === 'metaUpdated') { + this.syncCache(body.before, body.after); + } + } + } + @bindThis public dispose(): void { + this.redisForSub.off('message', this.onMessage); this.federatedInstanceCache.dispose(); } diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 2951691129..a0f2607ddc 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -235,7 +235,9 @@ export class HttpRequestService { } @bindThis - public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObjectWithId> { + public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> { + this.apUtilityService.assertApUrl(url); + const res = await this.send(url, { method: 'GET', headers: { @@ -253,7 +255,11 @@ export class HttpRequestService { // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. - this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + if (allowAnonymous && activity.id == null) { + activity.id = res.url; + } else { + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + } return activity as IObjectWithId; } diff --git a/packages/backend/src/core/InternalStorageService.ts b/packages/backend/src/core/InternalStorageService.ts index b00c5796d2..abdbbc61d3 100644 --- a/packages/backend/src/core/InternalStorageService.ts +++ b/packages/backend/src/core/InternalStorageService.ts @@ -6,18 +6,11 @@ import * as fs from 'node:fs'; import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises'; import * as Path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const path = Path.resolve(_dirname, '../../../../files'); - @Injectable() export class InternalStorageService { constructor( @@ -25,12 +18,12 @@ export class InternalStorageService { private config: Config, ) { // No one should erase the working directory *while the server is running*. - fs.mkdirSync(path, { recursive: true }); + fs.mkdirSync(this.config.mediaDirectory, { recursive: true }); } @bindThis public resolvePath(key: string) { - return Path.resolve(path, key); + return Path.resolve(this.config.mediaDirectory, key); } @bindThis diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 40e7439f5f..07f82dc23e 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { MiMeta } from '@/models/Meta.js'; @@ -12,6 +12,9 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; +import { MiInstance } from '@/models/Instance.js'; +import { diffArrays } from '@/misc/diff-arrays.js'; +import type { MetasRepository } from '@/models/_.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -26,6 +29,9 @@ export class MetaService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, + @Inject(DI.metasRepository) + private readonly metasRepository: MetasRepository, + private featuredService: FeaturedService, private globalEventService: GlobalEventService, ) { @@ -67,35 +73,35 @@ export class MetaService implements OnApplicationShutdown { public async fetch(noCache = false): Promise<MiMeta> { if (!noCache && this.cache) return this.cache; - return await this.db.transaction(async transactionalEntityManager => { - // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する - const metas = await transactionalEntityManager.find(MiMeta, { - order: { - id: 'DESC', - }, - }); + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + let meta = await this.metasRepository.createQueryBuilder('meta') + .select() + .orderBy({ + id: 'DESC', + }) + .limit(1) + .getOne(); - const meta = metas[0]; + if (!meta) { + await this.metasRepository.createQueryBuilder('meta') + .insert() + .values({ + id: 'x', + }) + .orIgnore() + .execute(); - if (meta) { - this.cache = meta; - return meta; - } else { - // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う - const saved = await transactionalEntityManager - .upsert( - MiMeta, - { - id: 'x', - }, - ['id'], - ) - .then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0])); + meta = await this.metasRepository.createQueryBuilder('meta') + .select() + .orderBy({ + id: 'DESC', + }) + .limit(1) + .getOneOrFail(); + } - this.cache = saved; - return saved; - } - }); + this.cache = meta; + return meta; } @bindThis @@ -103,7 +109,7 @@ export class MetaService implements OnApplicationShutdown { let before: MiMeta | undefined; const updated = await this.db.transaction(async transactionalEntityManager => { - const metas = await transactionalEntityManager.find(MiMeta, { + const metas: (MiMeta | undefined)[] = await transactionalEntityManager.find(MiMeta, { order: { id: 'DESC', }, @@ -126,6 +132,10 @@ export class MetaService implements OnApplicationShutdown { }, }); + // Propagate changes to blockedHosts, silencedHosts, mediaSilencedHosts, federationInstances, and bubbleInstances to the relevant instance rows + // Do this inside the transaction to avoid potential race condition (when an instance gets registered while we're updating). + await this.persistBlocks(transactionalEntityManager, before ?? {}, afters[0]); + return afters[0]; }); @@ -159,4 +169,49 @@ export class MetaService implements OnApplicationShutdown { public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } + + private async persistBlocks(tem: EntityManager, before: Partial<MiMeta>, after: Partial<MiMeta>): Promise<void> { + await this.persistBlock(tem, before.blockedHosts, after.blockedHosts, 'isBlocked'); + await this.persistBlock(tem, before.silencedHosts, after.silencedHosts, 'isSilenced'); + await this.persistBlock(tem, before.mediaSilencedHosts, after.mediaSilencedHosts, 'isMediaSilenced'); + await this.persistBlock(tem, before.federationHosts, after.federationHosts, 'isAllowListed'); + await this.persistBlock(tem, before.bubbleInstances, after.bubbleInstances, 'isBubbled'); + } + + private async persistBlock(tem: EntityManager, before: string[] | undefined, after: string[] | undefined, field: keyof MiInstance): Promise<void> { + const { added, removed } = diffArrays(before, after); + + if (removed.length > 0) { + await this.updateInstancesByHost(tem, field, false, removed); + } + + if (added.length > 0) { + await this.updateInstancesByHost(tem, field, true, added); + } + } + + private async updateInstancesByHost(tem: EntityManager, field: keyof MiInstance, value: boolean, hosts: string[]): Promise<void> { + // Use non-array queries when possible, as they are indexed and can be much faster. + if (hosts.length === 1) { + const pattern = genHostPattern(hosts[0]); + await tem + .createQueryBuilder(MiInstance, 'instance') + .update() + .set({ [field]: value }) + .where('(lower(reverse("host")) || \'.\') LIKE :pattern', { pattern }) + .execute(); + } else if (hosts.length > 1) { + const patterns = hosts.map(host => genHostPattern(host)); + await tem + .createQueryBuilder(MiInstance, 'instance') + .update() + .set({ [field]: value }) + .where('(lower(reverse("host")) || \'.\') LIKE ANY (:patterns)', { patterns }) + .execute(); + } + } +} + +function genHostPattern(host: string): string { + return host.toLowerCase().split('').reverse().join('') + '.%'; } diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 50a72e8aa6..cf2419a9eb 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -243,47 +243,44 @@ export class QueryService { q.andWhere(new Brackets(qb => { qb - .where(new Brackets(qb => { - qb.where('note.renoteId IS NOT NULL'); - qb.andWhere('note.text IS NULL'); - qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); - })) .orWhere('note.renoteId IS NULL') - .orWhere('note.text IS NOT NULL'); + .orWhere('note.text IS NOT NULL') + .orWhere('note.cw IS NOT NULL') + .orWhere('note.replyId IS NOT NULL') + .orWhere('note.hasPoll = false') + .orWhere('note.fileIds != \'{}\'') + .orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); })); q.setParameters(mutingQuery.getParameters()); } @bindThis - public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void { - let nonBlockedHostQuery: (part: string) => string; - if (this.meta.blockedHosts.length === 0) { - nonBlockedHostQuery = () => '1=1'; - } else { - nonBlockedHostQuery = (match: string) => `('.' || ${match}) NOT ILIKE ALL(select '%.' || x from (select unnest("blockedHosts") as x from "meta") t)`; - } + public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean, allowSilenced = true): void { + function checkFor(key: 'user' | 'replyUser' | 'renoteUser') { + q.leftJoin(`note.${key}Instance`, `${key}Instance`); + q.andWhere(new Brackets(qb => { + qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user + .orWhere(`note.${key}Host IS NULL`); // local - if (excludeAuthor) { - const instanceSuspension = (user: string) => new Brackets(qb => qb - .where(`note.${user}Id IS NULL`) // no corresponding user - .orWhere(`note.userId = note.${user}Id`) - .orWhere(`note.${user}Host IS NULL`) // local - .orWhere(nonBlockedHostQuery(`note.${user}Host`))); + if (allowSilenced) { + qb.orWhere(`${key}Instance.isBlocked = false`); // not blocked + } else { + qb.orWhere(new Brackets(qbb => qbb + .andWhere(`${key}Instance.isBlocked = false`) // not blocked + .andWhere(`${key}Instance.isSilenced = false`))); // not silenced + } - q - .andWhere(instanceSuspension('replyUser')) - .andWhere(instanceSuspension('renoteUser')); - } else { - const instanceSuspension = (user: string) => new Brackets(qb => qb - .where(`note.${user}Id IS NULL`) // no corresponding user - .orWhere(`note.${user}Host IS NULL`) // local - .orWhere(nonBlockedHostQuery(`note.${user}Host`))); + if (excludeAuthor) { + qb.orWhere(`note.userId = note.${key}Id`); // author + } + })); + } - q - .andWhere(instanceSuspension('user')) - .andWhere(instanceSuspension('replyUser')) - .andWhere(instanceSuspension('renoteUser')); + if (!excludeAuthor) { + checkFor('user'); } + checkFor('replyUser'); + checkFor('renoteUser'); } } diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index f05ee2ee73..86bf20067e 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; import { IdService } from '@/core/IdService.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; +import { MiNoteReaction } from '@/models/NoteReaction.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; @@ -31,6 +31,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; import { CacheService } from '@/core/CacheService.js'; +import type { DataSource } from 'typeorm'; const FALLBACK = '\u2764'; @@ -89,6 +90,9 @@ export class ReactionService { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.db) + private readonly db: DataSource, + private utilityService: UtilityService, private customEmojiService: CustomEmojiService, private roleService: RoleService, @@ -176,26 +180,28 @@ export class ReactionService { reaction, }; - try { - await this.noteReactionsRepository.insert(record); - } catch (e) { - if (isDuplicateKeyValueError(e)) { - const exists = await this.noteReactionsRepository.findOneByOrFail({ - noteId: note.id, - userId: user.id, - }); + const result = await this.db.transaction(async tem => { + await tem.createQueryBuilder(MiNoteReaction, 'noteReaction') + .insert() + .values(record) + .orIgnore() + .execute(); - if (exists.reaction !== reaction) { - // 別のリアクションがすでにされていたら置き換える - await this.delete(user, note); - await this.noteReactionsRepository.insert(record); - } else { - // 同じリアクションがすでにされていたらエラー - throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); - } - } else { - throw e; + return await tem.createQueryBuilder(MiNoteReaction, 'noteReaction') + .select() + .where({ noteId: note.id, userId: user.id }) + .getOneOrFail(); + }); + + if (result.id !== record.id) { + // Conflict with the same ID => nothing to do. + if (result.reaction === record.reaction) { + return; } + + // 別のリアクションがすでにされていたら置き換える + await this.delete(user, note); + await this.noteReactionsRepository.insert(record); } // Increment reactions count diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 8c0a8f6cc7..e31d9e5b1a 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -587,6 +587,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null, lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null, movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null, + instance: null, } : null, user2: parsed.user2 != null ? { ...parsed.user2, @@ -597,6 +598,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null, lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null, movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null, + instance: null, } : null, }; } else { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index d3c458eec7..b250eeee21 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -86,10 +86,10 @@ export const DEFAULT_POLICIES: RolePolicies = { canManageCustomEmojis: false, canManageAvatarDecorations: false, canSearchNotes: false, - canUseTranslator: true, + canUseTranslator: false, canHideAds: false, driveCapacityMb: 100, - maxFileSizeMb: 10, + maxFileSizeMb: 25, alwaysMarkNsfw: false, canUpdateBioMedia: true, pinLimit: 5, diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 170afc72dc..3098367392 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -49,22 +49,49 @@ export class UtilityService { return regexp.test(email); } + public isBlockedHost(host: string | null): boolean; + public isBlockedHost(blockedHosts: string[], host: string | null): boolean; @bindThis - public isBlockedHost(blockedHosts: string[], host: string | null): boolean { + public isBlockedHost(blockedHostsOrHost: string[] | string | null, host?: string | null): boolean { + const blockedHosts = Array.isArray(blockedHostsOrHost) ? blockedHostsOrHost : this.meta.blockedHosts; + host = Array.isArray(blockedHostsOrHost) ? host : blockedHostsOrHost; + if (host == null) return false; return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } + public isSilencedHost(host: string | null): boolean; + public isSilencedHost(silencedHosts: string[], host: string | null): boolean; @bindThis - public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { - if (!silencedHosts || host == null) return false; + public isSilencedHost(silencedHostsOrHost: string[] | string | null, host?: string | null): boolean { + const silencedHosts = Array.isArray(silencedHostsOrHost) ? silencedHostsOrHost : this.meta.silencedHosts; + host = Array.isArray(silencedHostsOrHost) ? host : silencedHostsOrHost; + + if (host == null) return false; return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } + public isMediaSilencedHost(host: string | null): boolean; + public isMediaSilencedHost(silencedHosts: string[], host: string | null): boolean; @bindThis - public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { - if (!silencedHosts || host == null) return false; - return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); + public isMediaSilencedHost(mediaSilencedHostsOrHost: string[] | string | null, host?: string | null): boolean { + const mediaSilencedHosts = Array.isArray(mediaSilencedHostsOrHost) ? mediaSilencedHostsOrHost : this.meta.mediaSilencedHosts; + host = Array.isArray(mediaSilencedHostsOrHost) ? host : mediaSilencedHostsOrHost; + + if (host == null) return false; + return mediaSilencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); + } + + @bindThis + public isAllowListedHost(host: string | null): boolean { + if (host == null) return false; + return this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); + } + + @bindThis + public isBubbledHost(host: string | null): boolean { + if (host == null) return false; + return this.meta.bubbleInstances.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } @bindThis diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 747fe4fc7e..3e4fd6a4b0 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -3,24 +3,41 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import fs from 'node:fs/promises'; import { Inject, Injectable } from '@nestjs/common'; import FFmpeg from 'fluent-ffmpeg'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { ImageProcessingService } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js'; -import { createTempDir } from '@/misc/create-temp.js'; +import { createTemp, createTempDir } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { appendQuery, query } from '@/misc/prelude/url.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; + +// faststart is only supported for MP4, M4A, M4W and MOV files (the MOV family). +// WebM (and Matroska) files always support faststart-like behavior. +const supportedMimeTypes = new Map([ + ['video/mp4', 'mp4'], + ['video/m4a', 'mp4'], + ['video/m4v', 'mp4'], + ['video/quicktime', 'mov'], +]); @Injectable() export class VideoProcessingService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, private imageProcessingService: ImageProcessingService, + + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('video-processing'); } @bindThis @@ -60,5 +77,50 @@ export class VideoProcessingService { }), ); } + + /** + * Optimize video for web playback by adding faststart flag. + * This allows the video to start playing before it is fully downloaded. + * The original file is modified in-place. + * @param source Path to the video file + * @param mimeType The MIME type of the video + * @returns Promise that resolves when optimization is complete + */ + @bindThis + public async webOptimizeVideo(source: string, mimeType: string): Promise<void> { + const outputFormat = supportedMimeTypes.get(mimeType); + if (!outputFormat) { + this.logger.debug(`Skipping web optimization for unsupported MIME type: ${mimeType}`); + return; + } + + const [tempPath, cleanup] = await createTemp(); + + try { + await new Promise<void>((resolve, reject) => { + FFmpeg(source) + .format(outputFormat) // Specify output format + .addOutputOptions('-c copy') // Copy streams without re-encoding + .addOutputOptions('-movflags +faststart') + .on('error', reject) + .on('end', async () => { + try { + // Replace original file with optimized version + await fs.copyFile(tempPath, source); + this.logger.info(`Web-optimized video: ${source}`); + resolve(); + } catch (copyError) { + reject(copyError); + } + }) + .save(tempPath); + }); + } catch (error) { + this.logger.warn(`Failed to web-optimize video: ${source}`, { error }); + throw error; + } finally { + cleanup(); + } + } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 406998dbc7..8c1508df24 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -63,6 +63,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser { emojis: [], score: 0, host: null, + instance: null, inbox: null, sharedInbox: null, featured: null, @@ -115,10 +116,13 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote { channelId: null, channel: null, userHost: null, + userInstance: null, replyUserId: null, replyUserHost: null, + replyUserInstance: null, renoteUserId: null, renoteUserHost: null, + renoteUserInstance: null, updatedAt: null, processErrors: [], ...override, @@ -450,6 +454,7 @@ export class WebhookTestService { isAdmin: false, isModerator: false, isSystem: false, + instance: undefined, ...override, }; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index b8526a972c..c06939eae2 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -36,7 +36,7 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import FederationChart from '@/core/chart/charts/federation.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; -import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -106,22 +106,25 @@ export class ApInboxService { let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; - // eslint-disable-next-line no-param-reassign resolver ??= this.apResolverService.createResolver(); - const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); - if (items.length >= resolver.getRecursionLimit()) { - throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`); - } - - for (const item of items) { - const act = await resolver.resolve(item); - if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { - this.logger.debug('skipping activity: activity id is null or mismatching'); - continue; + const items = await resolver.resolveCollectionItems(activity); + for (let i = 0; i < items.length; i++) { + const act = items[i]; + if (act.id != null) { + if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { + this.logger.warn('skipping activity: activity id mismatch'); + continue; + } + } else { + // Activity ID should only be string or undefined. + act.id = undefined; } + try { - results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]); + const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`; + const result = await this.performOneActivity(actor, act, resolver); + results.push([id, result]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -217,6 +220,10 @@ export class ApInboxService { const note = await this.apNoteService.resolveNote(object, { resolver }); if (!note) return `skip: target note not found ${targetUri}`; + if (note.userHost == null && note.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot react to local-only note'); + } + await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); try { @@ -371,6 +378,10 @@ export class ApInboxService { return 'skip: invalid actor for this activity'; } + if (renote.userHost == null && renote.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot renote a local-only note'); + } + this.logger.info(`Creating the (Re)Note: ${uri}`); const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver); diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 7118ce1e02..4c7cac2169 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -155,6 +155,8 @@ export class ApRequestService { @bindThis public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> { + this.apUtilityService.assertApUrl(url); + const body = typeof object === 'string' ? object : JSON.stringify(object); const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -182,10 +184,13 @@ export class ApRequestService { * Get AP object with http-signature * @param user http-signature user * @param url URL to fetch - * @param followAlternate + * @param allowAnonymous If a fetched object lacks an ID, then it will be auto-generated from the final URL. (default: false) + * @param followAlternate Whether to resolve HTML responses to their referenced canonical AP endpoint. (default: true) */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObjectWithId> { + public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> { + this.apUtilityService.assertApUrl(url); + const _followAlternate = followAlternate ?? true; const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -254,7 +259,7 @@ export class ApRequestService { if (alternate) { const href = alternate.getAttribute('href'); if (href && this.apUtilityService.haveSameAuthority(url, href)) { - return await this.signedGet(href, user, false); + return await this.signedGet(href, user, allowAnonymous, false); } } } catch { @@ -271,7 +276,11 @@ export class ApRequestService { // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. - this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + if (allowAnonymous && activity.id == null) { + activity.id = res.url; + } else { + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + } return activity as IObjectWithId; } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 5e58f848c0..7997eccced 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; +import promiseLimit from 'promise-limit'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -19,11 +20,12 @@ import { ApLogService, calculateDurationSince, extractObjectContext } from '@/co import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getApId, getNullableApId, IObjectWithId, isCollectionOrOrderedCollection } from './type.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ICollection, IOrderedCollection, ApObject } from './type.js'; +import type { IObject, ApObject, IAnonymousObject } from './type.js'; export class Resolver { private history: Set<string>; @@ -63,11 +65,16 @@ export class Resolver { return this.recursionLimit; } + public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection & IObjectWithId>; + public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise<AnyCollection & IObjectWithId>; + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection>; @bindThis - public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> { + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> { const collection = typeof value === 'string' - ? await this.resolve(value) - : value; + ? sentFromUri + ? await this.secureResolve(value, sentFromUri, allowAnonymous) + : await this.resolve(value, allowAnonymous) + : value; // TODO try and remove this eventually, as it's a major security foot-gun if (isCollectionOrOrderedCollection(collection)) { return collection; @@ -76,20 +83,110 @@ export class Resolver { } } + public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise<IAnonymousObject[]>; + public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObjectWithId[]>; + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObject[]>; + /** + * Recursively resolves items from a collection. + * Stops when reaching the resolution limit or an optional item limit - whichever is lower. + * This method supports Collection, OrderedCollection, and individual pages of either type. + * Malformed collections (mixing Ordered and un-Ordered types) are also supported. + * @param collection Collection to resolve from - can be a URL or object of any supported collection type. + * @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit. + * @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID. + * @param concurrency Maximum number of items to resolve at once. (default: 4) + */ + @bindThis + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise<IObject[]> { + const resolvedItems: IObject[] = []; + + // This is pulled up to avoid code duplication below + const iterate = async(items: ApObject, current: AnyCollection) => { + const sentFrom = current.id; + const itemArr = toArray(items); + const itemLimit = limit ?? Number.MAX_SAFE_INTEGER; + const allowAnonymous = allowAnonymousItems ?? false; + await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems); + }; + + let current: AnyCollection | null = await this.resolveCollection(collection); + do { + // Iterate all items in the current page + if (current.items) { + await iterate(current.items, current); + } + if (current.orderedItems) { + await iterate(current.orderedItems, current); + } + + if (this.history.size >= this.recursionLimit) { + // Stop when we reach the fetch limit + current = null; + } else if (limit != null && resolvedItems.length >= limit) { + // Stop when we reach the item limit + current = null; + } else if (isCollection(current) || isOrderedCollection(current)) { + // Continue to first page + current = current.first ? await this.resolveCollection(current.first, true, current.id) : null; + } else if (isCollectionPage(current) || isOrderedCollectionPage(current)) { + // Continue to next page + current = current.next ? await this.resolveCollection(current.next, true, current.id) : null; + } else { + // Stop in all other conditions + current = null; + } + } while (current != null); + + return resolvedItems; + } + + private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise<void>; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise<void>; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void>; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void> { + const recursionLimit = this.recursionLimit - this.history.size; + const batchLimit = Math.min(source.length, recursionLimit, itemLimit); + + const limiter = promiseLimit<IObject>(concurrency); + const batch = await Promise.all(source + .slice(0, batchLimit) + .map(item => limiter(async () => { + if (sentFrom) { + // Use secureResolve to avoid re-fetching items that were included inline. + return await this.secureResolve(item, sentFrom, allowAnonymousItems); + } else if (allowAnonymousItems) { + return await this.resolveAnonymous(item); + } else { + // ID is required if we have neither sentFrom not allowAnonymousItems + const id = getApId(item); + return await this.resolve(id); + } + }))); + + destination.push(...batch); + }; + /** * Securely resolves an AP object or URL that has been sent from another instance. * An input object is trusted if and only if its ID matches the authority of sentFromUri. * In all other cases, the object is re-fetched from remote by input string or object ID. + * @param input The input object or URL to resolve + * @param sentFromUri The URL where this object originated. This MUST be accurate - all security checks depend on this value! + * @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error. */ @bindThis - public async secureResolve(input: ApObject, sentFromUri: string): Promise<IObjectWithId> { + public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise<IObjectWithId> { // Unpack arrays to get the value element. const value = fromTuple(input); - if (value == null) { - throw new IdentifiableError('20058164-9de1-4573-8715-425753a21c1d', 'Cannot resolve null input'); + + // If anonymous input is allowed, then any object is automatically valid if we set the ID. + // We can short-circuit here and avoid un-necessary checks. + if (allowAnonymous && typeof(value) === 'object' && value.id == null) { + value.id = sentFromUri; + return value as IObjectWithId; } - // This will throw if the input has no ID, which is good because we can't verify an anonymous object anyway. + // This ensures the input has a string ID, protecting against type confusion and rejecting anonymous objects. const id = getApId(value); // Check if we can use the provided object as-is. @@ -100,28 +197,52 @@ export class Resolver { } // If the checks didn't pass, then we must fetch the object and use that. - return await this.resolve(id); + return await this.resolve(id, allowAnonymous); } - public async resolve(value: string | [string]): Promise<IObjectWithId>; - public async resolve(value: string | IObject | [string | IObject]): Promise<IObject>; + /** + * Resolves an anonymous object. + * The returned value will not have any ID present. + * If one is provided in the response, it will be removed automatically. + */ + @bindThis + public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise<IAnonymousObject> { + value = fromTuple(value); + + const object = await this.resolve(value); + object.id = undefined; + + return object as IAnonymousObject; + } + + public async resolve(value: string | [string], allowAnonymous?: boolean): Promise<IObjectWithId>; + public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise<IObjectWithId>; + public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise<IObject>; + /** + * Resolves a URL or object to an AP object. + * Tuples are expanded to their first element before anything else, and non-string inputs are returned as-is. + * Otherwise, the string URL is fetched and validated to represent a valid ActivityPub object. + * @param value The input value to resolve + * @param allowAnonymous Determines what to do if a response object lacks an ID field. If false (default), then an exception is thrown. If true, then the ID is populated from the final response URL. + */ @bindThis - public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> { + public async resolve(value: string | IObject | [string | IObject], allowAnonymous = false): Promise<IObject> { value = fromTuple(value); + // TODO try and remove this eventually, as it's a major security foot-gun if (typeof value !== 'string') { return value; } const host = this.utilityService.extractDbHost(value); if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) { - return await this._resolveLogged(value, host); + return await this._resolveLogged(value, host, allowAnonymous); } else { - return await this._resolve(value, host); + return await this._resolve(value, host, allowAnonymous); } } - private async _resolveLogged(requestUri: string, host: string): Promise<IObjectWithId> { + private async _resolveLogged(requestUri: string, host: string, allowAnonymous: boolean): Promise<IObjectWithId> { const startTime = process.hrtime.bigint(); const log = await this.apLogService.createFetchLog({ @@ -130,7 +251,7 @@ export class Resolver { }); try { - const result = await this._resolve(requestUri, host, log); + const result = await this._resolve(requestUri, host, allowAnonymous, log); log.accepted = true; log.result = 'ok'; @@ -150,7 +271,7 @@ export class Resolver { } } - private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise<IObjectWithId> { + private async _resolve(value: string, host: string, allowAnonymous: boolean, log?: SkApFetchLog): Promise<IObjectWithId> { if (value.includes('#')) { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). @@ -181,8 +302,8 @@ export class Resolver { } const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) - : await this.httpRequestService.getActivityJson(value)); + ? await this.apRequestService.signedGet(value, this.user, allowAnonymous) + : await this.httpRequestService.getActivityJson(value, false, allowAnonymous)); if (log) { const { object: objectOnly, context, contextHash } = extractObjectContext(object); diff --git a/packages/backend/src/core/activitypub/ApUtilityService.ts b/packages/backend/src/core/activitypub/ApUtilityService.ts index ae6e4997e4..3c125c6cd9 100644 --- a/packages/backend/src/core/activitypub/ApUtilityService.ts +++ b/packages/backend/src/core/activitypub/ApUtilityService.ts @@ -78,15 +78,41 @@ export class ApUtilityService { } /** + * Verifies that a provided URL is in a format acceptable for federation. + * @throws {IdentifiableError} If URL cannot be parsed + * @throws {IdentifiableError} If URL is not HTTPS + */ + public assertApUrl(url: string | URL): void { + // If string, parse and validate + if (typeof(url) === 'string') { + try { + url = new URL(url); + } catch { + throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`); + } + } + + // Must be HTTPS + if (!this.checkHttps(url)) { + throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`); + } + } + + /** * Checks if the URL contains HTTPS. * Additionally, allows HTTP in non-production environments. * Based on check-https.ts. */ - private checkHttps(url: string): boolean { + private checkHttps(url: string | URL): boolean { const isNonProd = this.envService.env.NODE_ENV !== 'production'; - // noinspection HttpUrlsUsage - return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); + try { + const proto = new URL(url).protocol; + return proto === 'https:' || (proto === 'http:' && isNonProd); + } catch { + // Invalid URLs don't "count" as HTTPS + return false; + } } } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index f6152e3888..5b66031bee 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -95,6 +95,7 @@ export class ApNoteService { actor?: MiRemoteUser, user?: MiRemoteUser, ): Error | null { + this.apUtilityService.assertApUrl(uri); const expectHost = this.utilityService.extractDbHost(uri); const apType = getApType(object); @@ -284,6 +285,13 @@ export class ApNoteService { const quote = await this.getQuote(note, entryUri, resolver); const processErrors = quote === null ? ['quoteUnavailable'] : null; + if (reply && reply.userHost == null && reply.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note'); + } + if (quote && quote.userHost == null && quote.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note'); + } + // vote if (reply && reply.hasPoll) { const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); @@ -481,6 +489,10 @@ export class ApNoteService { const quote = await this.getQuote(note, entryUri, resolver); const processErrors = quote === null ? ['quoteUnavailable'] : null; + if (quote && quote.userHost == null && quote.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note'); + } + // vote if (reply && reply.hasPoll) { const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index bf5a5e01d5..ef8b58ff87 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -153,6 +153,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { */ @bindThis private validateActor(x: IObject, uri: string): IActor { + this.apUtilityService.assertApUrl(uri); const expectHost = this.utilityService.punyHostPSLDomain(uri); if (!isActor(x)) { @@ -167,6 +168,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`); } + this.apUtilityService.assertApUrl(x.inbox); const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox); if (inboxHost !== expectHost) { throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`); @@ -175,6 +177,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); if (sharedInboxObject != null) { const sharedInbox = getApId(sharedInboxObject); + this.apUtilityService.assertApUrl(sharedInbox); if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) { throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`); } @@ -185,6 +188,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { if (xCollection != null) { const collectionUri = getApId(xCollection); if (typeof collectionUri === 'string' && collectionUri.length > 0) { + this.apUtilityService.assertApUrl(collectionUri); if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) { throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`); } @@ -352,8 +356,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { const [followingVisibility, followersVisibility] = await Promise.all( [ - this.isPublicCollection(person.following, resolver), - this.isPublicCollection(person.followers, resolver), + this.isPublicCollection(person.following, resolver, uri), + this.isPublicCollection(person.followers, resolver, uri), ].map((p): Promise<'public' | 'private'> => p .then(isPublic => isPublic ? 'public' : 'private') .catch(err => { @@ -389,10 +393,18 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { //#endregion //#region resolve counts - const _resolver = resolver ?? this.apResolverService.createResolver(); - const outboxcollection = await _resolver.resolveCollection(person.outbox).catch(() => { return null; }); - const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; }); - const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; }); + const outboxCollection = person.outbox + ? await resolver.resolveCollection(person.outbox, true, uri).catch(() => { return null; }) + : null; + const followersCollection = person.followers + ? await resolver.resolveCollection(person.followers, true, uri).catch(() => { return null; }) + : null; + const followingCollection = person.following + ? await resolver.resolveCollection(person.following, true, uri).catch(() => { return null; }) + : null; + + // Register the instance first, to avoid FK errors + await this.federatedInstanceService.fetchOrRegister(host); try { // Start transaction @@ -419,9 +431,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { host, inbox: person.inbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, - notesCount: outboxcollection?.totalItems ?? 0, - followersCount: followerscollection?.totalItems ?? 0, - followingCount: followingcollection?.totalItems ?? 0, + notesCount: outboxCollection?.totalItems ?? 0, + followersCount: followersCollection?.totalItems ?? 0, + followingCount: followingCollection?.totalItems ?? 0, followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured ? getApId(person.featured) : undefined, uri: person.id, @@ -571,8 +583,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { const [followingVisibility, followersVisibility] = await Promise.all( [ - this.isPublicCollection(person.following, resolver), - this.isPublicCollection(person.followers, resolver), + this.isPublicCollection(person.following, resolver, exist.uri), + this.isPublicCollection(person.followers, resolver, exist.uri), ].map((p): Promise<'public' | 'private' | undefined> => p .then(isPublic => isPublic ? 'public' : 'private') .catch(err => { @@ -797,13 +809,13 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { const _resolver = resolver ?? this.apResolverService.createResolver(); // Resolve to (Ordered)Collection Object - const collection = await _resolver.resolveCollection(user.featured).catch(err => { + const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => { if (err instanceof AbortError || err instanceof StatusError) { this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`); } else { this.logger.error('Failed to update featured notes:', err); } - }); + }) : null; if (!collection) return; if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`); @@ -889,11 +901,13 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { } @bindThis - private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> { + private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver, sentFrom: string): Promise<boolean> { if (collection) { - const resolved = await resolver.resolveCollection(collection); - if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) { - return true; + const resolved = await resolver.resolveCollection(collection, true, sentFrom).catch(() => null); + if (resolved) { + if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) { + return true; + } } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 0122697f2a..362c3af1e7 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -43,6 +43,18 @@ export interface IObjectWithId extends IObject { id: string; } +export function isObjectWithId(object: IObject): object is IObjectWithId { + return typeof(object.id) === 'string'; +} + +export interface IAnonymousObject extends IObject { + id: undefined; +} + +export function isAnonymousObject(object: IObject): object is IAnonymousObject { + return object.id === undefined; +} + /** * Get array of ActivityStreams Objects id */ @@ -125,48 +137,46 @@ export interface IActivity extends IObject { }; } -export interface ICollection extends IObject { - type: 'Collection'; - totalItems: number; +export interface CollectionBase extends IObject { + totalItems?: number; first?: IObject | string; last?: IObject | string; current?: IObject | string; + partOf?: IObject | string; + next?: IObject | string; + prev?: IObject | string; items?: ApObject; + orderedItems?: ApObject; } -export interface IOrderedCollection extends IObject { +export interface ICollection extends CollectionBase { + type: 'Collection'; + totalItems: number; + items?: ApObject; + orderedItems?: undefined; +} + +export interface IOrderedCollection extends CollectionBase { type: 'OrderedCollection'; totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; + items?: undefined; orderedItems?: ApObject; } -export interface ICollectionPage extends IObject { +export interface ICollectionPage extends CollectionBase { type: 'CollectionPage'; - totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; - partOf?: IObject | string; - next?: IObject | string; - prev?: IObject | string; items?: ApObject; + orderedItems?: undefined; } -export interface IOrderedCollectionPage extends IObject { +export interface IOrderedCollectionPage extends CollectionBase { type: 'OrderedCollectionPage'; - totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; - partOf?: IObject | string; - next?: IObject | string; - prev?: IObject | string; + items?: undefined; orderedItems?: ApObject; } +export type AnyCollection = ICollection | IOrderedCollection | ICollectionPage | IOrderedCollectionPage; + export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; export const isPost = (object: IObject): object is IPost => { @@ -270,7 +280,7 @@ export const isCollectionPage = (object: IObject): object is ICollectionPage => export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage => getApType(object) === 'OrderedCollectionPage'; -export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => +export const isCollectionOrOrderedCollection = (object: IObject): object is AnyCollection => isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object); export interface IApPropertyValue extends IObject { diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index bf702884ca..b6db6f5454 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -44,10 +44,6 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di } protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { - const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') - .select('instance.host') - .where('instance.suspensionState != \'none\''); - const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') .where('f.followerHost IS NOT NULL'); @@ -64,22 +60,25 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') - .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .innerJoin('following.followeeInstance', 'followeeInstance') + .andWhere('followeeInstance.suspensionState = \'none\'') + .andWhere('followeeInstance.isBlocked = false') .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followerHost)') .where('following.followerHost IS NOT NULL') - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followerHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') - .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .innerJoin('following.followerInstance', 'followerInstance') + .andWhere('followerInstance.isBlocked = false') + .andWhere('followerInstance.suspensionState = \'none\'') .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') - .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .innerJoin('following.followeeInstance', 'followeeInstance') + .andWhere('followeeInstance.isBlocked = false') + .andWhere('followeeInstance.suspensionState = \'none\'') .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .setParameters(pubsubSubQuery.getParameters()) .getRawOne() @@ -87,7 +86,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') + .andWhere('instance.isBlocked = false') .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() @@ -95,7 +94,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') + .andWhere('instance.isBlocked = false') .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index fcc9bed3bd..a2ee4b0505 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -43,7 +43,7 @@ export class InstanceEntityService { isNotResponding: instance.isNotResponding, isSuspended: instance.suspensionState !== 'none', suspensionState: instance.suspensionState, - isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), + isBlocked: instance.isBlocked, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, openRegistrations: instance.openRegistrations, @@ -51,8 +51,8 @@ export class InstanceEntityService { description: instance.description, maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, - isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host), - isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host), + isSilenced: instance.isSilenced, + isMediaSilenced: instance.isMediaSilenced, iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, @@ -62,6 +62,7 @@ export class InstanceEntityService { rejectReports: instance.rejectReports, rejectQuotes: instance.rejectQuotes, moderationNote: iAmModerator ? instance.moderationNote : null, + isBubbled: this.utilityService.isBubbledHost(instance.host), }; } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 77e6a1c7e7..cc8edfc666 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -23,6 +23,13 @@ import type { NoteEntityService } from './NoteEntityService.js'; const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); +function undefOnMissing<T>(packPromise: Promise<T>): Promise<T | undefined> { + return packPromise.catch(err => { + if (err instanceof EntityNotFoundError) return undefined; + throw err; + }); +} + @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -75,9 +82,9 @@ export class NotificationEntityService implements OnModuleInit { const noteIfNeed = needsNote ? ( hint?.packedNotes != null ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId, { id: meId }, { + : undefOnMissing(this.noteEntityService.pack(notification.noteId, { id: meId }, { detail: true, - }) + })) ) : undefined; // if the note has been deleted, don't show this notification if (needsNote && !noteIfNeed) return null; @@ -86,7 +93,7 @@ export class NotificationEntityService implements OnModuleInit { const userIfNeed = needsUser ? ( hint?.packedUsers != null ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId, { id: meId }) + : undefOnMissing(this.userEntityService.pack(notification.notifierId, { id: meId })) ) : undefined; // if the user has been deleted, don't show this notification if (needsUser && !userIfNeed) return null; @@ -96,7 +103,7 @@ export class NotificationEntityService implements OnModuleInit { const reactions = (await Promise.all(notification.reactions.map(async reaction => { const user = hint?.packedUsers != null ? hint.packedUsers.get(reaction.userId)! - : await this.userEntityService.pack(reaction.userId, { id: meId }); + : await undefOnMissing(this.userEntityService.pack(reaction.userId, { id: meId })); return { user, reaction: reaction.reaction, @@ -121,7 +128,7 @@ export class NotificationEntityService implements OnModuleInit { return packedUser; } - return this.userEntityService.pack(userId, { id: meId }); + return undefOnMissing(this.userEntityService.pack(userId, { id: meId })); }))).filter(x => x != null); // if all users have been deleted, don't show this notification if (users.length === 0) { @@ -140,10 +147,7 @@ export class NotificationEntityService implements OnModuleInit { const needsRole = notification.type === 'roleAssigned'; const role = needsRole - ? await this.roleEntityService.pack(notification.roleId).catch(err => { - if (err instanceof EntityNotFoundError) return undefined; - throw err; - }) + ? await undefOnMissing(this.roleEntityService.pack(notification.roleId)) : undefined; // if the role has been deleted, don't show this notification if (needsRole && !role) { diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index f66a36336d..f6aeb0ef8b 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -610,7 +610,7 @@ export class UserEntityService implements OnModuleInit { requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined, makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined, - instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { + instance: user.host ? this.federatedInstanceService.fetch(user.host).then(instance => instance ? { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index ba44cfa2e6..9a50eb8561 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -11,6 +11,7 @@ const envOption = { verbose: false, withLogTime: false, quiet: false, + hideWorkerId: false, }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index b3735200eb..ca9b494ff2 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -71,7 +71,9 @@ export default class Logger { level === 'info' ? message : null; - let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`; + let log = envOption.hideWorkerId + ? `${l}\t[${contexts.join(' ')}]\t\t${m}` + : `${l} ${worker}\t[${contexts.join(' ')}]\t\t${m}`; if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; const args: unknown[] = [important ? chalk.bold(log) : log]; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 48b8f43678..a6ab96c189 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -308,8 +308,17 @@ export class MemoryKVCache<T> { } } + /** + * Removes all entries from the cache, but does not dispose it. + */ + @bindThis + public clear(): void { + this.cache.clear(); + } + @bindThis public dispose(): void { + this.clear(); clearInterval(this.gcIntervalHandle); } diff --git a/packages/backend/src/misc/diff-arrays.ts b/packages/backend/src/misc/diff-arrays.ts new file mode 100644 index 0000000000..b50ca1d4f7 --- /dev/null +++ b/packages/backend/src/misc/diff-arrays.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface DiffResult<T> { + added: T[]; + removed: T[]; +} + +/** + * Calculates the difference between two snapshots of data. + * Null, undefined, and empty arrays are supported, and duplicate values are ignored. + * Result sets are de-duplicated, and will be empty if no data was added or removed (respectively). + * The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change. + * @param dataBefore Array containing data before the change + * @param dataAfter Array containing data after the change + */ +export function diffArrays<T>(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): DiffResult<T> { + const before = dataBefore ? new Set(dataBefore) : null; + const after = dataAfter ? new Set(dataAfter) : null; + + // data before AND after => changed + if (before?.size && after?.size) { + const added: T[] = []; + const removed: T[] = []; + + for (const host of before) { + // before and NOT after => removed + // delete operation removes duplicates to speed up the "after" loop + if (!after.delete(host)) { + removed.push(host); + } + } + + for (const host of after) { + // after and NOT before => added + if (!before.has(host)) { + added.push(host); + } + } + + return { added, removed }; + } + + // data ONLY before => all removed + if (before?.size) { + return { added: [], removed: Array.from(before) }; + } + + // data ONLY after => all added + if (after?.size) { + return { added: Array.from(after), removed: [] }; + } + + // data NEITHER before nor after => no change + return { added: [], removed: [] }; +} + +/** + * Checks for any difference between two snapshots of data. + * Null, undefined, and empty arrays are supported, and duplicate values are ignored. + * The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change. + * @param dataBefore Array containing data before the change + * @param dataAfter Array containing data after the change + */ +export function diffArraysSimple<T>(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): boolean { + const before = dataBefore ? new Set(dataBefore) : null; + const after = dataAfter ? new Set(dataAfter) : null; + + if (before?.size && after?.size) { + // different size => changed + if (before.size !== after.size) return true; + + // removed => changed + for (const host of before) { + // delete operation removes duplicates to speed up the "after" loop + if (!after.delete(host)) { + return true; + } + } + + // added => changed + for (const host of after) { + if (!before.has(host)) { + return true; + } + } + + // identical values => no change + return false; + } + + // before and NOT after => change + if (before?.size) return true; + + // after and NOT before => change + if (after?.size) return true; + + // NEITHER before nor after => no change + return false; +} diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..0aa1b13976 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -4,6 +4,7 @@ */ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -66,6 +67,16 @@ export class MiFollowing { }) public followerHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'followerHost', + foreignKeyConstraintName: 'FK_following_followerHost', + referencedColumnName: 'host', + }) + public followerInstance: MiInstance | null; + @Column('varchar', { length: 512, nullable: true, comment: '[Denormalized]', @@ -85,6 +96,16 @@ export class MiFollowing { }) public followeeHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'followeeHost', + foreignKeyConstraintName: 'FK_following_followeeHost', + referencedColumnName: 'host', + }) + public followeeInstance: MiInstance | null; + @Column('varchar', { length: 512, nullable: true, comment: '[Denormalized]', diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index c64ebb1b3b..0022e58933 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -6,6 +6,7 @@ import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; import { id } from './util/id.js'; +@Index('IDX_instance_host_key', { synchronize: false }) @Entity('instance') export class MiInstance { @PrimaryColumn(id()) @@ -98,6 +99,56 @@ export class MiInstance { }) public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'; + /** + * True if this instance is blocked from federation. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is blocked from federation.', + }) + public isBlocked: boolean; + + /** + * True if this instance is allow-listed. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is allow-listed.', + }) + public isAllowListed: boolean; + + /** + * True if this instance is part of the local bubble. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is part of the local bubble.', + }) + public isBubbled: boolean; + + /** + * True if this instance is silenced. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is silenced.', + }) + public isSilenced: boolean; + + /** + * True if this instance is media-silenced. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is media-silenced.', + }) + public isMediaSilenced: boolean; + @Column('varchar', { length: 64, nullable: true, comment: 'The software of the Instance.', diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index ee2098216d..fa5839b6ec 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -5,6 +5,7 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; import { noteVisibilities } from '@/types.js'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; @@ -222,6 +223,16 @@ export class MiNote { }) public userHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userHost', + foreignKeyConstraintName: 'FK_note_userHost', + referencedColumnName: 'host', + }) + public userInstance: MiInstance | null; + @Column({ ...id(), nullable: true, @@ -235,6 +246,16 @@ export class MiNote { }) public replyUserHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'replyUserHost', + foreignKeyConstraintName: 'FK_note_replyUserHost', + referencedColumnName: 'host', + }) + public replyUserInstance: MiInstance | null; + @Column({ ...id(), nullable: true, @@ -247,6 +268,16 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; + + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'renoteUserHost', + foreignKeyConstraintName: 'FK_note_renoteUserHost', + referencedColumnName: 'host', + }) + public renoteUserInstance: MiInstance | null; //#endregion constructor(data: Partial<MiNote>) { diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index d5f572a879..3ef5817672 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn, ManyToOne } from 'typeorm'; import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; @@ -292,6 +293,16 @@ export class MiUser { }) public host: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'host', + foreignKeyConstraintName: 'FK_user_host', + referencedColumnName: 'host', + }) + public instance: MiInstance | null; + @Column('varchar', { length: 512, nullable: true, comment: 'The inbox URL of the User. It will be null if the origin of the user is local.', diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 57d4466ffa..fd6eddf594 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -135,5 +135,9 @@ export const packedFederationInstanceSchema = { type: 'string', optional: true, nullable: true, }, + isBubbled: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 632fd58927..b4bd934972 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -98,9 +98,12 @@ pg.types.setTypeParser(20, Number); export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); +const sqlMigrateLogger = sqlLogger.createSubLogger('migrate'); +const sqlSchemaLogger = sqlLogger.createSubLogger('schema'); export type LoggerProps = { disableQueryTruncation?: boolean; + enableQueryLogging?: boolean; enableQueryParamLogging?: boolean; printReplicationMode?: boolean, }; @@ -112,7 +115,7 @@ function highlightSql(sql: string) { } function truncateSql(sql: string) { - return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql; + return sql.length > 100 ? `${sql.substring(0, 100)} [truncated]` : sql; } function stringifyParameter(param: any) { @@ -136,7 +139,7 @@ class MyCustomLogger implements Logger { modded = truncateSql(modded); } - return highlightSql(modded); + return this.props.enableQueryLogging ? highlightSql(modded) : modded; } @bindThis @@ -150,6 +153,8 @@ class MyCustomLogger implements Logger { @bindThis public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + if (!this.props.enableQueryLogging) return; + const prefix = (this.props.printReplicationMode && queryRunner) ? `[${queryRunner.getReplicationMode()}] ` : undefined; @@ -161,7 +166,8 @@ class MyCustomLogger implements Logger { const prefix = (this.props.printReplicationMode && queryRunner) ? `[${queryRunner.getReplicationMode()}] ` : undefined; - sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); + const transformed = this.transformQueryLog(query, { prefix }); + sqlLogger.error(`Query error (${error}): ${transformed}`, this.transformParameters(parameters)); } @bindThis @@ -169,22 +175,32 @@ class MyCustomLogger implements Logger { const prefix = (this.props.printReplicationMode && queryRunner) ? `[${queryRunner.getReplicationMode()}] ` : undefined; - sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); + const transformed = this.transformQueryLog(query, { prefix }); + sqlLogger.warn(`Query is slow (${time}ms): ${transformed}`, this.transformParameters(parameters)); } @bindThis public logSchemaBuild(message: string) { - sqlLogger.info(message); + sqlSchemaLogger.debug(message); } @bindThis - public log(message: string) { - sqlLogger.info(message); + public log(level: 'log' | 'info' | 'warn', message: string) { + switch (level) { + case 'log': + case 'info': { + sqlLogger.info(message); + break; + } + case 'warn': { + sqlLogger.warn(message); + } + } } @bindThis public logMigration(message: string) { - sqlLogger.info(message); + sqlMigrateLogger.debug(message); } } @@ -306,7 +322,7 @@ export function createPostgresDataSource(config: Config) { } : {}), synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', - cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) + cache: config.db.disableCache === false && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) type: 'ioredis', options: { ...config.redis, @@ -314,14 +330,13 @@ export function createPostgresDataSource(config: Config) { }, } : false, logging: log, - logger: log - ? new MyCustomLogger({ - disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, - enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, - printReplicationMode: !!config.dbReplications, - }) - : undefined, - maxQueryExecutionTime: 300, + logger: new MyCustomLogger({ + disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, + enableQueryLogging: log, + enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + printReplicationMode: !!config.dbReplications, + }), + maxQueryExecutionTime: config.db.slowQueryThreshold, entities: entities, migrations: ['../../migration/*.js'], }); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 0d2dafd556..5c9e5717bb 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -344,14 +344,14 @@ export class ApiCallService implements OnApplicationShutdown { } if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) { - if (user == null) { + if (user == null && ep.meta.requireCredential !== 'optional') { throw new ApiError({ message: 'Credential required.', code: 'CREDENTIAL_REQUIRED', id: '1384574d-a912-4b81-8601-c7b1c4085df1', httpStatusCode: 401, }); - } else if (user!.isSuspended) { + } else if (user?.isSuspended) { throw new ApiError({ message: 'Your account has been suspended.', code: 'YOUR_ACCOUNT_SUSPENDED', @@ -372,8 +372,8 @@ export class ApiCallService implements OnApplicationShutdown { } } - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { - const myRoles = await this.roleService.getUserRoles(user!.id); + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user?.id)) { + const myRoles = user ? await this.roleService.getUserRoles(user) : []; if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a moderator role.', @@ -392,9 +392,9 @@ export class ApiCallService implements OnApplicationShutdown { } } - if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) { - const myRoles = await this.roleService.getUserRoles(user!.id); - const policies = await this.roleService.getUserPolicies(user!.id); + if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user?.id)) { + const myRoles = user ? await this.roleService.getUserRoles(user) : []; + const policies = await this.roleService.getUserPolicies(user ?? null); if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a required role.', @@ -418,7 +418,7 @@ export class ApiCallService implements OnApplicationShutdown { // Cast non JSON input if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; + const param = ep.params.properties[k]; if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { try { data[k] = JSON.parse(data[k]); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 0ba041c536..c7d884cce1 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -92,7 +92,7 @@ export type IEndpointMeta = (Omit<IEndpointMetaBase, 'requireCrential' | 'requir }) | (Omit<IEndpointMetaBase, 'secure'> & { secure: true, }) | (Omit<IEndpointMetaBase, 'requireCredential' | 'kind'> & { - requireCredential: true, + requireCredential: true | 'optional', kind: (typeof permissions)[number], }) | (Omit<IEndpointMetaBase, 'requireModerator' | 'kind'> & { requireModerator: true, diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 1579719246..6a77fc177f 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -122,6 +122,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isAdministrator: { + type: 'boolean', + optional: false, nullable: false, + }, isSystem: { type: 'boolean', optional: false, nullable: false, @@ -257,6 +261,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } const isModerator = await this.roleService.isModerator(user); + const isAdministrator = await this.roleService.isAdministrator(user); const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote; const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); @@ -289,6 +294,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- mutedInstances: profile.mutedInstances, notificationRecieveConfig: profile.notificationRecieveConfig, isModerator: isModerator, + isAdministrator: isAdministrator, isSystem: isSystemAccount(user), isSilenced: isSilenced, isSuspended: user.isSuspended, diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index b90ba6aa0d..7e79f0dccc 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -121,6 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); const notes = await query.getMany(); if (sinceId != null && untilId == null) { diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 14286bc23e..06dd37a140 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; +import { isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage } from '@/core/activitypub/type.js'; export const meta = { tags: ['federation'], @@ -33,6 +34,9 @@ export const paramDef = { type: 'object', properties: { uri: { type: 'string' }, + expandCollectionItems: { type: 'boolean' }, + expandCollectionLimit: { type: 'integer', nullable: true }, + allowAnonymous: { type: 'boolean' }, }, required: ['uri'], } as const; @@ -44,7 +48,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ) { super(meta, paramDef, async (ps, me) => { const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(ps.uri); + const object = await resolver.resolve(ps.uri, ps.allowAnonymous ?? false); + + if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) { + const items = await resolver.resolveCollectionItems(object, ps.expandCollectionLimit, ps.allowAnonymous ?? false); + + if (isOrderedCollection(object) || isOrderedCollectionPage(object)) { + object.orderedItems = items; + } else { + object.items = items; + } + } + return object; }); } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 6336f43e9f..99ae1c2211 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -138,9 +138,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateVisibilityQuery(query, me); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } if (ps.withRenotes === false) { diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts index dcdcf46d0b..9f5064fe83 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts index 28c64229e7..68dc87546e 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index 69ff3c5d7a..c0bfb00608 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index bd870cc3d9..bd15700670 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index 765bf024ee..e1053d05d8 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index ecac436311..4550e2f17e 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts index 98ec40ade2..9475a8ab0a 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts index cb3dd36bab..20d0ecb25d 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts index 0742a21210..1d24dc2b77 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/pv.ts b/packages/backend/src/server/api/endpoints/charts/user/pv.ts index a220381b00..e0026d5ff3 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/pv.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/pv.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts index 3bb33622c2..c15056466f 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index b5452517ab..0f96fae202 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 59513e530d..4758dbad00 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -92,10 +92,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } const notes = await query diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts index df030d90aa..17c9b31c90 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -74,18 +74,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.btlDisabled); } - const [ - followings, - ] = me ? await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]) : [undefined]; + const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : undefined; //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') - .andWhere('note.userHost IN (:...hosts)', { hosts: this.serverSettings.bubbleInstances }) + .andWhere('note.userHost IS NOT NULL') + .andWhere('userInstance.isBubbled = true') // This comes from generateBlockedHostQueryForNote below .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') @@ -97,6 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + if (!me) query.andWhere('user.requireSigninToViewContents = false'); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); @@ -104,21 +102,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.where('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.where('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); + if (!ps.withRenotes) { + query.andWhere(new Brackets(qb => qb + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL') + .orWhere('note.cw IS NOT NULL') + .orWhere('note.replyId IS NOT NULL') + .orWhere('note.hasPoll = false') + .orWhere('note.fileIds != \'{}\''))); } //#endregion let timeline = await query.limit(ps.limit).getMany(); timeline = timeline.filter(note => { - if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; + if (note.user?.isSilenced) { + if (!me) return false; + if (!followings) return false; + if (note.userId !== me.id) { + return followings[note.userId]; + } + } return true; }); diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 5f6ee9f903..088b172ba4 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { SkLatestNote, MiFollowing } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -130,7 +130,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel') + + // Exclude channel notes + .andWhere({ channelId: IsNull() }) ; // Limit to files, if requested @@ -145,11 +147,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // Hide blocked users / instances query.andWhere('"user"."isSuspended" = false'); - query.andWhere('("replyUser" IS NULL OR "replyUser"."isSuspended" = false)'); - query.andWhere('("renoteUser" IS NULL OR "renoteUser"."isSuspended" = false)'); this.queryService.generateBlockedHostQueryForNote(query); - // Respect blocks and mutes + // Respect blocks, mutes, and privacy + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); @@ -161,7 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // Query and return the next page const notes = await query.getMany(); - return await this.noteEntityService.packMany(notes, me); + return await this.noteEntityService.packMany(notes, me, { skipHide: true }); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index 33a9c281b3..6f96821a63 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -9,13 +9,13 @@ import type { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepo import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '@/server/api/error.js'; export const meta = { tags: ['notes'], - requireCredential: true, - kind: 'read:account', - res: { type: 'array', optional: false, nullable: false, @@ -26,10 +26,24 @@ export const meta = { }, }, - // 2 calls per second + errors: { + ltlDisabled: { + message: 'Local timeline has been disabled.', + code: 'LTL_DISABLED', + id: '45a6eb02-7695-4393-b023-dd3be9aaaefd', + }, + gtlDisabled: { + message: 'Global timeline has been disabled.', + code: 'GTL_DISABLED', + id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b', + }, + }, + + // Up to 10 calls, then 2 per second limit: { - duration: 1000, - max: 2, + type: 'bucket', + size: 10, + dripRate: 500, }, } as const; @@ -39,6 +53,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, excludeChannels: { type: 'boolean', default: false }, + local: { type: 'boolean', nullable: true, default: null }, + expired: { type: 'boolean', default: false }, }, required: [], } as const; @@ -59,18 +75,54 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private mutingsRepository: MutingsRepository, private noteEntityService: NoteEntityService, + private readonly queryService: QueryService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const query = this.pollsRepository.createQueryBuilder('poll') - .where('poll.userHost IS NULL') - .andWhere('poll.userId != :meId', { meId: me.id }) - .andWhere('poll.noteVisibility = \'public\'') - .andWhere(new Brackets(qb => { + .innerJoinAndSelect('poll.note', 'note') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('reply.user', 'replyUser') + .andWhere('user.isExplorable = TRUE') + ; + + if (me) { + query.andWhere('poll.userId != :meId', { meId: me.id }); + } + + if (ps.expired) { + query.andWhere('poll.expiresAt IS NOT NULL'); + query.andWhere('poll.expiresAt <= :expiresMax', { + expiresMax: new Date(), + }); + query.andWhere('poll.expiresAt >= :expiresMin', { + expiresMin: new Date(Date.now() - (1000 * 60 * 60 * 24 * 7)), + }); + } else { + query.andWhere(new Brackets(qb => { qb .where('poll.expiresAt IS NULL') .orWhere('poll.expiresAt > :now', { now: new Date() }); })); + } + + const policies = await this.roleService.getUserPolicies(me?.id ?? null); + if (ps.local != null) { + if (ps.local) { + if (!policies.ltlAvailable) throw new ApiError(meta.errors.ltlDisabled); + query.andWhere('poll.userHost IS NULL'); + } else { + if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled); + query.andWhere('poll.userHost IS NOT NULL'); + } + } else { + if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled); + } + /* //#region exclude arleady voted polls const votedQuery = this.pollVotesRepository.createQueryBuilder('vote') .select('vote.noteId') @@ -81,16 +133,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- query.setParameters(votedQuery.getParameters()); //#endregion + */ - //#region mute - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - query - .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); - - query.setParameters(mutingQuery.getParameters()); + //#region block/mute/vis + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + } //#endregion //#region exclude channels @@ -107,6 +158,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (polls.length === 0) return []; + /* const notes = await this.notesRepository.find({ where: { id: In(polls.map(poll => poll.noteId)), @@ -115,6 +167,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- id: 'DESC', }, }); + */ + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const notes = polls.map(poll => poll.note!); return await this.noteEntityService.packMany(notes, me, { detail: true, 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 91874a8195..5c1ab0fb78 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 @@ -96,10 +96,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateBlockedHostQueryForNote(query, undefined, false); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {}; @@ -160,7 +160,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (note.user?.isSuspended) return false; if (note.userHost) { if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false; - if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false; } return true; }); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index a97542c063..e55168e296 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -20,11 +20,9 @@ import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], - // TODO allow unauthenticated if default template allows? - // Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role. - // This will allow unauthenticated requests without leaking post data to restricted clients. - requireCredential: true, + requireCredential: 'optional', kind: 'read:account', + requiredRolePolicy: 'canUseTranslator', res: { type: 'object', @@ -88,17 +86,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private readonly loggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me.id); - if (!policies.canUseTranslator) { - throw new ApiError(meta.errors.unavailable); - } - const note = await this.getterService.getNote(ps.noteId).catch(err => { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); - if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) { + if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null))) { throw new ApiError(meta.errors.cannotTranslateInvisibleNote); } @@ -140,7 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey); params.append('text', note.text); params.append('target_lang', targetLang); - const endpoint = deeplFreeInstance ?? this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + const endpoint = deeplFreeInstance ?? ( this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate' ); const res = await this.httpRequestService.send(endpoint, { method: 'POST', diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index d1c2e4b686..536384a381 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -107,10 +107,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); const notes = await query.getMany(); notes.sort((a, b) => a.id > b.id ? -1 : 1); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index 56f59bd285..553787ad58 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -105,10 +105,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) - .leftJoinAndSelect('reaction.note', 'note'); + .innerJoinAndSelect('reaction.note', 'note'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } const reactions = (await query .limit(ps.limit) diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 375ea1ef08..02ce31c4f8 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -252,10 +252,10 @@ export class MastodonConverters { return await this.convertStatus(status, me); } - public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise<MastodonEntity.Status> { + public async convertStatus(status: Entity.Status, me: MiLocalUser | null, hints?: { note?: MiNote, user?: MiUser }): Promise<MastodonEntity.Status> { const convertedAccount = this.convertAccount(status.account); - const note = await this.mastodonDataService.requireNote(status.id, me); - const noteUser = await this.getUser(status.account.id); + const note = hints?.note ?? await this.mastodonDataService.requireNote(status.id, me); + const noteUser = hints?.user ?? note.user ?? await this.getUser(status.account.id); const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host); diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts index db257756de..73cd553b9a 100644 --- a/packages/backend/src/server/api/mastodon/MastodonDataService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -7,8 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; -import type { MiNote, NotesRepository } from '@/models/_.js'; -import type { MiLocalUser } from '@/models/User.js'; +import type { MiChannel, MiNote, NotesRepository } from '@/models/_.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; import { ApiError } from '../error.js'; /** @@ -27,8 +27,8 @@ export class MastodonDataService { /** * Fetches a note in the context of the current user, and throws an exception if not found. */ - public async requireNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote> { - const note = await this.getNote(noteId, me); + public async requireNote<Rel extends NoteRelations = NoteRelations>(noteId: string, me: MiLocalUser | null | undefined, relations?: Rel): Promise<NoteWithRelations<Rel>> { + const note = await this.getNote(noteId, me, relations); if (!note) { throw new ApiError({ @@ -46,12 +46,39 @@ export class MastodonDataService { /** * Fetches a note in the context of the current user. */ - public async getNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote | null> { + public async getNote<Rel extends NoteRelations = NoteRelations>(noteId: string, me: MiLocalUser | null | undefined, relations?: Rel): Promise<NoteWithRelations<Rel> | null> { // Root query: note + required dependencies const query = this.notesRepository .createQueryBuilder('note') - .where('note.id = :noteId', { noteId }) - .innerJoinAndSelect('note.user', 'user'); + .where('note.id = :noteId', { noteId }); + + // Load relations + if (relations) { + if (relations.reply) { + query.leftJoinAndSelect('note.reply', 'reply'); + if (typeof(relations.reply) === 'object') { + if (relations.reply.reply) query.leftJoinAndSelect('note.reply.reply', 'replyReply'); + if (relations.reply.renote) query.leftJoinAndSelect('note.reply.renote', 'replyRenote'); + if (relations.reply.user) query.innerJoinAndSelect('note.reply.user', 'replyUser'); + if (relations.reply.channel) query.leftJoinAndSelect('note.reply.channel', 'replyChannel'); + } + } + if (relations.renote) { + query.leftJoinAndSelect('note.renote', 'renote'); + if (typeof(relations.renote) === 'object') { + if (relations.renote.reply) query.leftJoinAndSelect('note.renote.reply', 'renoteReply'); + if (relations.renote.renote) query.leftJoinAndSelect('note.renote.renote', 'renoteRenote'); + if (relations.renote.user) query.innerJoinAndSelect('note.renote.user', 'renoteUser'); + if (relations.renote.channel) query.leftJoinAndSelect('note.renote.channel', 'renoteChannel'); + } + } + if (relations.user) { + query.innerJoinAndSelect('note.user', 'user'); + } + if (relations.channel) { + query.leftJoinAndSelect('note.channel', 'channel'); + } + } // Restrict visibility this.queryService.generateVisibilityQuery(query, me); @@ -59,7 +86,7 @@ export class MastodonDataService { this.queryService.generateBlockedUserQueryForNotes(query, me); } - return await query.getOne(); + return await query.getOne() as NoteWithRelations<Rel> | null; } /** @@ -82,3 +109,41 @@ export class MastodonDataService { }); } } + +interface NoteRelations { + reply?: boolean | { + reply?: boolean; + renote?: boolean; + user?: boolean; + channel?: boolean; + }; + renote?: boolean | { + reply?: boolean; + renote?: boolean; + user?: boolean; + channel?: boolean; + }; + user?: boolean; + channel?: boolean; +} + +type NoteWithRelations<Rel extends NoteRelations> = MiNote & { + reply: Rel extends { reply: false } + ? null + : null | (MiNote & { + reply: Rel['reply'] extends { reply: true } ? MiNote | null : null; + renote: Rel['reply'] extends { renote: true } ? MiNote | null : null; + user: Rel['reply'] extends { user: true } ? MiUser : null; + channel: Rel['reply'] extends { channel: true } ? MiChannel | null : null; + }); + renote: Rel extends { renote: false } + ? null + : null | (MiNote & { + reply: Rel['renote'] extends { reply: true } ? MiNote | null : null; + renote: Rel['renote'] extends { renote: true } ? MiNote | null : null; + user: Rel['renote'] extends { user: true } ? MiUser : null; + channel: Rel['renote'] extends { channel: true } ? MiChannel | null : null; + }); + user: Rel extends { user: true } ? MiUser : null; + channel: Rel extends { channel: true } ? MiChannel | null : null; +}; diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 22b8a911ca..7a058a0ed9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -8,6 +8,10 @@ import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { isPureRenote } from '@/misc/is-renote.js'; import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -22,6 +26,7 @@ export class ApiStatusMastodon { constructor( private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, + private readonly mastodonDataService: MastodonDataService, ) {} public register(fastify: FastifyInstance): void { @@ -29,13 +34,24 @@ export class ApiStatusMastodon { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const note = await this.mastodonDataService.requireNote(_request.params.id, me, { user: true, renote: { user: true } }); + + // Unpack renote for Discord, otherwise the preview breaks + const appearNote = (isPureRenote(note) && _request.headers['user-agent']?.match(/\bDiscordbot\//)) + ? note.renote as NonNullable<typeof note.renote> + : note; + + const data = await client.getStatus(appearNote.id); + const response = await this.mastoConverters.convertStatus(data.data, me, { note: appearNote, user: appearNote.user }); // Fixup - Discord ignores CWs and renders the entire post. if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) { - response.content = '(preview disabled for sensitive content)'; + response.content = getNoteSummary(data.data satisfies Packed<'Note'>); response.media_attachments = []; + response.in_reply_to_id = null; + response.in_reply_to_account_id = null; + response.reblog = null; + response.quote = null; } return reply.send(response); @@ -170,7 +186,7 @@ export class ApiStatusMastodon { const data = await client.deleteEmojiReaction(id, react); return reply.send(data.data); } - if (!body.media_ids) body.media_ids = undefined; + body.media_ids ??= undefined; if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; if (body.poll && !body.poll.options) { diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 9af816dfbb..204ea9f705 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -65,6 +65,9 @@ export default abstract class Channel { * ミュートとブロックされてるを処理する */ protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // Ignore notes that require sign-in + if (note.user.requireSigninToViewContents && !this.user) return true; + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index d29101cbc5..88cb9937b3 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -12,6 +12,7 @@ import { RoleService } from '@/core/RoleService.js'; import type { MiMeta } from '@/models/Meta.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { UtilityService } from '@/core/UtilityService.js'; import Channel, { MiChannelService } from '../channel.js'; class BubbleTimelineChannel extends Channel { @@ -26,6 +27,7 @@ class BubbleTimelineChannel extends Channel { constructor( private metaService: MetaService, private roleService: RoleService, + private readonly utilityService: UtilityService, noteEntityService: NoteEntityService, id: string, @@ -56,12 +58,15 @@ class BubbleTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; if (note.user.host == null) return; - if (!this.instance.bubbleInstances.includes(note.user.host)) return; + if (!this.utilityService.isBubbledHost(note.user.host)) return; if (note.user.requireSigninToViewContents && this.user == null) return; if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + if (note.user.isSilenced) { + if (!this.user) return; + if (note.userId !== this.user.id && !this.following[note.userId]) return; + } if (this.isNoteMutedOrBlocked(note)) return; @@ -88,6 +93,7 @@ export class BubbleTimelineChannelService implements MiChannelService<false> { private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, + private readonly utilityService: UtilityService, ) { } @@ -96,6 +102,7 @@ export class BubbleTimelineChannelService implements MiChannelService<false> { return new BubbleTimelineChannel( this.metaService, this.roleService, + this.utilityService, this.noteEntityService, id, connection, diff --git a/packages/backend/test-federation/tsconfig.json b/packages/backend/test-federation/tsconfig.json index 3a1cb3b9f3..16b333f877 100644 --- a/packages/backend/test-federation/tsconfig.json +++ b/packages/backend/test-federation/tsconfig.json @@ -3,7 +3,7 @@ /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ diff --git a/packages/backend/test-server/tsconfig.json b/packages/backend/test-server/tsconfig.json index 10313699c2..cb394ecccd 100644 --- a/packages/backend/test-server/tsconfig.json +++ b/packages/backend/test-server/tsconfig.json @@ -23,6 +23,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "rootDir": "../src", "baseUrl": "./", "paths": { diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 2b562acda8..f3b6a5108d 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -23,6 +23,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "baseUrl": "./", "paths": { "@/*": ["../src/*"] diff --git a/packages/backend/test/unit/MetaService.ts b/packages/backend/test/unit/MetaService.ts index 19c98eab3d..056838e180 100644 --- a/packages/backend/test/unit/MetaService.ts +++ b/packages/backend/test/unit/MetaService.ts @@ -11,6 +11,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { CoreModule } from '@/core/CoreModule.js'; +import { MetasRepository } from '@/models/_.js'; import type { TestingModule } from '@nestjs/testing'; import type { DataSource } from 'typeorm'; @@ -39,8 +40,8 @@ describe('MetaService', () => { }); test('fetch (cache)', async () => { - const db = app.get<DataSource>(DI.db); - const spy = jest.spyOn(db, 'transaction'); + const metasRepository = app.get<MetasRepository>(DI.metasRepository); + const spy = jest.spyOn(metasRepository, 'createQueryBuilder'); const result = await metaService.fetch(); @@ -49,12 +50,12 @@ describe('MetaService', () => { }); test('fetch (force)', async () => { - const db = app.get<DataSource>(DI.db); - const spy = jest.spyOn(db, 'transaction'); + const metasRepository = app.get<MetasRepository>(DI.metasRepository); + const spy = jest.spyOn(metasRepository, 'createQueryBuilder'); const result = await metaService.fetch(true); expect(result.id).toBe('x'); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalled(); }); }); diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f4ecfef34d..63e3795a84 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -57,10 +57,13 @@ describe('NoteCreateService', () => { channelId: null, channel: null, userHost: null, + userInstance: null, replyUserId: null, replyUserHost: null, + replyUserInstance: null, renoteUserId: null, renoteUserHost: null, + renoteUserInstance: null, processErrors: [], }; diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 553ff0982a..839402418e 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -15,6 +15,7 @@ import type { MockFunctionMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { + InstancesRepository, MiMeta, MiRole, MiRoleAssignment, @@ -39,6 +40,7 @@ const moduleMocker = new ModuleMocker(global); describe('RoleService', () => { let app: TestingModule; let roleService: RoleService; + let instancesRepository: InstancesRepository; let usersRepository: UsersRepository; let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; @@ -47,6 +49,19 @@ describe('RoleService', () => { let clock: lolex.InstalledClock; async function createUser(data: Partial<MiUser> = {}) { + if (data.host != null) { + await instancesRepository + .createQueryBuilder('instance') + .insert() + .values({ + id: genAidx(Date.now()), + firstRetrievedAt: new Date(), + host: data.host, + }) + .orIgnore() + .execute(); + } + const un = secureRndstr(16); const x = await usersRepository.insert({ id: genAidx(Date.now()), @@ -145,6 +160,7 @@ describe('RoleService', () => { app.enableShutdownHooks(); roleService = app.get<RoleService>(RoleService); + instancesRepository = app.get<InstancesRepository>(DI.instancesRepository); usersRepository = app.get<UsersRepository>(DI.usersRepository); rolesRepository = app.get<RolesRepository>(DI.rolesRepository); roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository); diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts index 697425beb8..a6b331d1cb 100644 --- a/packages/backend/test/unit/UserSearchService.ts +++ b/packages/backend/test/unit/UserSearchService.ts @@ -7,16 +7,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { describe, jest, test } from '@jest/globals'; import { In } from 'typeorm'; import { UserSearchService } from '@/core/UserSearchService.js'; -import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { FollowingsRepository, InstancesRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { genAidx } from '@/misc/id/aidx.js'; describe('UserSearchService', () => { let app: TestingModule; let service: UserSearchService; + let instancesRepository: InstancesRepository; let usersRepository: UsersRepository; let followingsRepository: FollowingsRepository; let idService: IdService; @@ -35,6 +37,19 @@ describe('UserSearchService', () => { let bobby: MiUser; async function createUser(data: Partial<MiUser> = {}) { + if (data.host != null) { + await instancesRepository + .createQueryBuilder('instance') + .insert() + .values({ + id: genAidx(Date.now()), + firstRetrievedAt: new Date(), + host: data.host, + }) + .orIgnore() + .execute(); + } + const user = await usersRepository .insert({ id: idService.gen(), @@ -104,6 +119,7 @@ describe('UserSearchService', () => { await app.init(); + instancesRepository = app.get<InstancesRepository>(DI.instancesRepository); usersRepository = app.get(DI.usersRepository); userProfilesRepository = app.get(DI.userProfilesRepository); followingsRepository = app.get(DI.followingsRepository); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 6f6d4c4121..94dec16401 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -103,6 +103,25 @@ describe('ActivityPub', () => { let config: Config; const metaInitial = { + id: 'x', + name: 'Test Instance', + shortName: 'Test Instance', + description: 'Test Instance', + langs: [] as string[], + pinnedUsers: [] as string[], + hiddenTags: [] as string[], + prohibitedWordsForNameOfUser: [] as string[], + silencedHosts: [] as string[], + mediaSilencedHosts: [] as string[], + policies: {}, + serverRules: [] as string[], + bannedEmailDomains: [] as string[], + preservedUsernames: [] as string[], + bubbleInstances: [] as string[], + trustedLinkUrlPatterns: [] as string[], + federation: 'all', + federationHosts: [] as string[], + allowUnsignedFetch: 'always', cacheRemoteFiles: true, cacheRemoteSensitiveFiles: true, enableFanoutTimeline: true, diff --git a/packages/backend/test/unit/misc/diff-arrays.ts b/packages/backend/test/unit/misc/diff-arrays.ts new file mode 100644 index 0000000000..b6db5e2eca --- /dev/null +++ b/packages/backend/test/unit/misc/diff-arrays.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js'; + +describe(diffArrays, () => { + it('should return empty result when both inputs are null', () => { + const result = diffArrays(null, null); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should return empty result when both inputs are empty', () => { + const result = diffArrays([], []); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should remove before when after is empty', () => { + const result = diffArrays([1, 2, 3], []); + expect(result.added).toHaveLength(0); + expect(result.removed).toEqual([1, 2, 3]); + }); + + it('should deduplicate before when after is empty', () => { + const result = diffArrays([1, 1, 2, 2, 3], []); + expect(result.added).toHaveLength(0); + expect(result.removed).toEqual([1, 2, 3]); + }); + + it('should add after when before is empty', () => { + const result = diffArrays([], [1, 2, 3]); + expect(result.added).toEqual([1, 2, 3]); + expect(result.removed).toHaveLength(0); + }); + + it('should deduplicate after when before is empty', () => { + const result = diffArrays([], [1, 1, 2, 2, 3]); + expect(result.added).toEqual([1, 2, 3]); + expect(result.removed).toHaveLength(0); + }); + + it('should return diff when both have values', () => { + const result = diffArrays( + ['a', 'b', 'c', 'd'], + ['a', 'c', 'e', 'f'], + ); + expect(result.added).toEqual(['e', 'f']); + expect(result.removed).toEqual(['b', 'd']); + }); +}); + +describe(diffArraysSimple, () => { + it('should return false when both inputs are null', () => { + const result = diffArraysSimple(null, null); + expect(result).toBe(false); + }); + + it('should return false when both inputs are empty', () => { + const result = diffArraysSimple([], []); + expect(result).toBe(false); + }); + + it('should return true when before is populated and after is empty', () => { + const result = diffArraysSimple([1, 2, 3], []); + expect(result).toBe(true); + }); + + it('should return true when before is empty and after is populated', () => { + const result = diffArraysSimple([], [1, 2, 3]); + expect(result).toBe(true); + }); + + it('should return true when values have changed', () => { + const result = diffArraysSimple( + ['a', 'a', 'b', 'c'], + ['a', 'b', 'c', 'd'], + ); + expect(result).toBe(true); + }); + + it('should return false when values have not changed', () => { + const result = diffArraysSimple( + ['a', 'a', 'b', 'c'], + ['a', 'b', 'c', 'c'], + ); + expect(result).toBe(false); + }); +}); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 24cd2236bb..b6cfa53466 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -40,10 +40,13 @@ const base: MiNote = { channelId: null, channel: null, userHost: null, + userInstance: null, replyUserId: null, replyUserHost: null, + replyUserInstance: null, renoteUserId: null, renoteUserHost: null, + renoteUserInstance: null, processErrors: [], }; diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 392da169ad..afed1f186c 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -23,6 +23,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "rootDir": "./src", "baseUrl": "./", "paths": { diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 658a9f732d..1a851df49b 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -18,7 +18,7 @@ "@transfem-org/sfm-js": "0.24.5", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.3", - "@vue/compiler-sfc": "3.5.12", + "@vue/compiler-sfc": "3.5.14", "astring": "1.9.0", "buraha": "0.0.1", "estree-walker": "3.0.3", @@ -35,7 +35,7 @@ "typescript": "5.8.3", "uuid": "11.1.0", "vite": "6.3.3", - "vue": "3.5.12" + "vue": "3.5.14" }, "devDependencies": { "@misskey-dev/summaly": "5.2.1", @@ -49,7 +49,7 @@ "@typescript-eslint/eslint-plugin": "8.31.0", "@typescript-eslint/parser": "8.31.0", "@vitest/coverage-v8": "3.1.2", - "@vue/runtime-core": "3.5.12", + "@vue/runtime-core": "3.5.14", "acorn": "8.14.1", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss index ba3238cd4c..529bb606be 100644 --- a/packages/frontend-embed/src/style.scss +++ b/packages/frontend-embed/src/style.scss @@ -101,7 +101,7 @@ rt { } } -.ti { +.ti, ph-lg { width: 1.28em; vertical-align: -12%; line-height: 1em; diff --git a/packages/frontend-embed/src/workers/tsconfig.json b/packages/frontend-embed/src/workers/tsconfig.json index 8ee8930465..39ba45ddbb 100644 --- a/packages/frontend-embed/src/workers/tsconfig.json +++ b/packages/frontend-embed/src/workers/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { "lib": ["esnext", "webworker"], + "incremental": true } } diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json index e0ee08188d..8db5776c91 100644 --- a/packages/frontend-embed/tsconfig.json +++ b/packages/frontend-embed/tsconfig.json @@ -23,6 +23,7 @@ "useDefineForClassFields": true, "verbatimModuleSyntax": true, "skipLibCheck": true, + "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json index 8f76763e10..0512b50caf 100644 --- a/packages/frontend-shared/tsconfig.json +++ b/packages/frontend-shared/tsconfig.json @@ -18,6 +18,7 @@ "esModuleInterop": true, "verbatimModuleSyntax": true, "skipLibCheck": true, + "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./*"], diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json index f325114522..18baf516ba 100644 --- a/packages/frontend/.storybook/tsconfig.json +++ b/packages/frontend/.storybook/tsconfig.json @@ -18,6 +18,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "incremental": true, "jsx": "react", "jsxFactory": "h" }, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a20aba0201..f5c7bcf1b4 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -30,7 +30,7 @@ "@transfem-org/sfm-js": "0.24.6", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.3", - "@vue/compiler-sfc": "3.5.12", + "@vue/compiler-sfc": "3.5.14", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "astring": "1.9.0", "broadcast-channel": "7.1.0", @@ -76,7 +76,7 @@ "uuid": "11.1.0", "v-code-diff": "1.13.1", "vite": "6.3.3", - "vue": "3.5.12", + "vue": "3.5.14", "vuedraggable": "next", "wanakana": "5.3.1" }, @@ -119,8 +119,8 @@ "@typescript-eslint/eslint-plugin": "8.31.0", "@typescript-eslint/parser": "8.31.0", "@vitest/coverage-v8": "3.1.2", - "@vue/compiler-core": "3.5.12", - "@vue/runtime-core": "3.5.12", + "@vue/compiler-core": "3.5.14", + "@vue/runtime-core": "3.5.14", "acorn": "8.14.1", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", diff --git a/packages/frontend/src/components/DynamicNote.vue b/packages/frontend/src/components/DynamicNote.vue index a5008e9ddf..67707bfda9 100644 --- a/packages/frontend/src/components/DynamicNote.vue +++ b/packages/frontend/src/components/DynamicNote.vue @@ -23,10 +23,10 @@ import type MkNote from '@/components/MkNote.vue'; import type SkNote from '@/components/SkNote.vue'; import { prefer } from '@/preferences'; -const XNote = computed(() => - prefer.r.noteDesign.value === 'misskey' - ? defineAsyncComponent(() => import('@/components/MkNote.vue')) - : defineAsyncComponent(() => import('@/components/SkNote.vue')), +const XNote = defineAsyncComponent(() => + prefer.s.noteDesign === 'misskey' + ? import('@/components/MkNote.vue') + : import('@/components/SkNote.vue') ); const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl'); diff --git a/packages/frontend/src/components/DynamicNoteDetailed.vue b/packages/frontend/src/components/DynamicNoteDetailed.vue index 21bf00ccbe..8594db2328 100644 --- a/packages/frontend/src/components/DynamicNoteDetailed.vue +++ b/packages/frontend/src/components/DynamicNoteDetailed.vue @@ -20,10 +20,10 @@ import type MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import type SkNoteDetailed from '@/components/SkNoteDetailed.vue'; import { prefer } from '@/preferences'; -const XNoteDetailed = computed(() => - prefer.r.noteDesign.value === 'misskey' - ? defineAsyncComponent(() => import('@/components/MkNoteDetailed.vue')) - : defineAsyncComponent(() => import('@/components/SkNoteDetailed.vue')), +const XNoteDetailed = defineAsyncComponent(() => + prefer.s.noteDesign === 'misskey' + ? import('@/components/MkNoteDetailed.vue') + : import('@/components/SkNoteDetailed.vue'), ); const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl'); diff --git a/packages/frontend/src/components/DynamicNoteSimple.vue b/packages/frontend/src/components/DynamicNoteSimple.vue index 7ca345ccfa..5eaeaf6c23 100644 --- a/packages/frontend/src/components/DynamicNoteSimple.vue +++ b/packages/frontend/src/components/DynamicNoteSimple.vue @@ -21,10 +21,10 @@ import type MkNoteSimple from '@/components/MkNoteSimple.vue'; import type SkNoteSimple from '@/components/SkNoteSimple.vue'; import { prefer } from '@/preferences'; -const XNoteSimple = computed(() => - prefer.r.noteDesign.value === 'misskey' - ? defineAsyncComponent(() => import('@/components/MkNoteSimple.vue')) - : defineAsyncComponent(() => import('@/components/SkNoteSimple.vue')), +const XNoteSimple = defineAsyncComponent(() => + prefer.s.noteDesign === 'misskey' + ? import('@/components/MkNoteSimple.vue') + : import('@/components/SkNoteSimple.vue'), ); const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl'); diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 63e6b74154..8cf4e5fa2d 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -16,6 +16,7 @@ import { instance } from '@/instance.js'; import { prefer } from '@/preferences.js'; import { getDateText } from '@/utility/timeline-date-separate.js'; import { $i } from '@/i.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; export default defineComponent({ props: { @@ -146,14 +147,12 @@ export default defineComponent({ [$style['direction-up']]: props.direction === 'up', }; - return () => prefer.s.animation ? h(TransitionGroup, { + return () => h(SkTransitionGroup, { class: classes, name: 'list', tag: 'div', onBeforeLeave, onLeaveCancelled, - }, { default: renderChildren }) : h('div', { - class: classes, }, { default: renderChildren }); }, }); diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 43d2002204..dfdfc0a871 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -53,6 +53,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { miLocalStorage } from '@/local-storage.js'; import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'closed'): void; @@ -66,6 +67,7 @@ function close() { } function neverShow() { + prefer.commit('neverShowDonationInfo', 'true'); miLocalStorage.setItem('neverShowDonationInfo', 'true'); close(); } diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 2e5d0a3dea..4537bc9d82 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened"> - <MkStickyContainer> + <MkStickyContainer :sticky="sticky"> <template #header> <button :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> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkStickyContainer> + <MkStickyContainer :sticky="sticky"> <template #header> <div v-if="$slots.header" :class="$style.inBodyHeader"> <slot name="header"></slot> @@ -73,12 +73,14 @@ const props = withDefaults(defineProps<{ withSpacer?: boolean; spacerMin?: number; spacerMax?: number; + sticky?: boolean; }>(), { defaultOpen: false, maxHeight: null, withSpacer: true, spacerMin: 14, spacerMax: 22, + sticky: true, }); const rootEl = useTemplateRef('rootEl'); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 6a356ffd37..34c33270bb 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -154,13 +154,13 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else class="ph-smiley ph-bold ph-lg"></i> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" @mousedown.prevent="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -226,7 +226,7 @@ import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { focusPrev, focusNext } from '@/utility/focus.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; @@ -360,7 +360,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 92a3c7b5cc..b2f237a82e 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -166,13 +166,13 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else class="ph-smiley ph-bold ph-lg"></i> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -278,7 +278,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; @@ -388,7 +388,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index b6a18ccab6..e6c0e83a8e 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="$style.noteFooterButton" :style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''" - @mousedown="renoted ? undoRenote() : boostVisibility($event.shiftKey)" + @click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)" > <i class="ph-rocket-launch ph-bold ph-lg"></i> <p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> @@ -42,24 +42,30 @@ SPDX-License-Identifier: AGPL-3.0-only ref="quoteButton" class="_button" :class="$style.noteFooterButton" - @mousedown="quote()" + @click.stop="quote()" > <i class="ph-quotes ph-bold ph-lg"></i> </button> <button v-else class="_button" :class="$style.noteFooterButton" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> + <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @click.stop="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click.stop="react()"> <i v-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> </button> <button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> <i class="ph-minus ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()"> + <i class="ti ti-paperclip"></i> + </button> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -78,10 +84,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, shallowRef, watch } from 'vue'; +import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import * as config from '@@/js/config.js'; +import type { Ref } from 'vue'; import type { Visibility } from '@/utility/boost-quote.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; @@ -101,11 +108,12 @@ import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { reactionPicker } from '@/utility/reaction-picker.js'; import { claimAchievement } from '@/utility/achievements.js'; -import { getNoteMenu } from '@/utility/get-note-menu.js'; +import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; import { prefer } from '@/preferences.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; +import { instance, policies } from '@/instance'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -128,6 +136,7 @@ const translating = ref(false); const isDeleted = ref(false); const renoted = ref(false); const reactButton = shallowRef<HTMLElement>(); +const clipButton = useTemplateRef('clipButton'); const renoteButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); @@ -153,6 +162,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); +const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); + async function addReplyTo(replyNote: Misskey.entities.Note) { replies.value.unshift(replyNote); appearNote.value.repliesCount += 1; @@ -376,6 +387,14 @@ function menu(): void { os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } +async function clip(): Promise<void> { + os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); +} + +async function translate() { + await translateNote(appearNote.value.id, translation, translating); +} + if (props.detail) { misskeyApi('notes/children', { noteId: props.note.id, diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index efb481d01d..3028c48c4f 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -15,13 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> - <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/> - <div :class="$style.ad"> - <MkAd :preferForms="['horizontal', 'horizontal-big']"/> - </div> - </div> - <DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/> </template> </div> </template> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 5b2a8dfc5a..220ca04d47 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -242,13 +242,18 @@ watch(props, async () => { const type = props.notification.type; // To avoid extra lookups, only do the query when it actually matters. - if (type === 'follow' || type === 'receiveFollowRequest') { - const user = await misskeyApi('users/show', { - userId: props.notification.userId, - }); + if ((type === 'follow' || type === 'receiveFollowRequest') && props.notification.userId) { + try { + const user = await misskeyApi('users/show', { + userId: props.notification.userId, + }); - userDetailed.value = user; - followRequestDone.value = !user.hasPendingFollowRequestToYou; + userDetailed.value = user; + followRequestDone.value = !user.hasPendingFollowRequestToYou; + } catch { + userDetailed.value = null; + followRequestDone.value = false; + } } else { userDetailed.value = null; followRequestDone.value = false; diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 55ee054285..46e98462dc 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items: notifications }"> - <component - :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]" + <SkTransitionGroup + :class="[$style.notifications]" :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" @@ -23,11 +23,11 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass=" $style.transition_x_move" tag="div" > - <template v-for="(notification, i) in notifications" :key="notification.id"> + <div v-for="(notification, i) in notifications" :key="notification.id"> <DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/> <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/> - </template> - </component> + </div> + </SkTransitionGroup> </template> </MkPagination> </MkPullToRefresh> @@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { prefer } from '@/preferences.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 000ccf50bf..c1d78301de 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -557,6 +557,7 @@ async function toggleLocalOnly() { if (confirm.result === 'no') return; if (confirm.result === 'neverShow') { + prefer.commit('neverShowLocalOnlyInfo', 'true'); miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true'); } } diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 70d0ddca0a..88ac8c87c1 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<component - :is="prefer.s.animation ? TransitionGroup : 'div'" +<SkTransitionGroup :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" @@ -14,8 +13,10 @@ SPDX-License-Identifier: AGPL-3.0-only tag="div" :class="$style.root" > <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> - <slot v-if="hasMoreReactions" name="more"/> -</component> + <div v-if="hasMoreReactions" :key="'$more'" :class="$style.moreReactions"> + <slot name="more"/> + </div> +</SkTransitionGroup> </template> <script lang="ts" setup> @@ -25,6 +26,7 @@ import { TransitionGroup } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -102,7 +104,7 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe position: absolute; } -.root { +.root, .moreReactions { display: flex; flex-wrap: wrap; align-items: center; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 87e8f0e258..834664db26 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -14,8 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items: notes }"> - <component - :is="prefer.s.animation ? TransitionGroup : 'div'" + <SkTransitionGroup :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]" :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" @@ -24,16 +23,11 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass=" $style.transition_x_move" tag="div" > - <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> - <DynamicNote :class="$style.note" :note="note" :withHardMute="true"/> - <div :class="$style.ad"> - <MkAd :preferForms="['horizontal', 'horizontal-big']"/> - </div> - </div> - <DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> - </template> - </component> + <div v-for="(note, i) in notes" :key="note.id" :class="{ '_gaps': !noGap }"> + <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/> + </div> + </SkTransitionGroup> </template> </MkPagination> </MkPullToRefresh> @@ -54,6 +48,7 @@ import DynamicNote from '@/components/DynamicNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; diff --git a/packages/frontend/src/components/SkBadgeStrip.vue b/packages/frontend/src/components/SkBadgeStrip.vue new file mode 100644 index 0000000000..6611d35b07 --- /dev/null +++ b/packages/frontend/src/components/SkBadgeStrip.vue @@ -0,0 +1,84 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.badges"> + <div + v-for="badge of badges" + :key="badge.key" + :class="[$style.badge, semanticClass(badge)]" + > + {{ badge.label }} + </div> +</div> +</template> + +<script lang="ts"> +export interface Badge { + /** + * ID/key of this badge, must be unique within the strip. + */ + key: string; + + /** + * Label text to display. + * Should already be translated. + */ + label: string; + + /** + * Semantic style of the badge. + * Defaults to "neutral" if unset. + */ + style?: 'success' | 'neutral' | 'warning' | 'error'; +} +</script> + +<script setup lang="ts"> +import { useCssModule } from 'vue'; + +const $style = useCssModule(); + +defineProps<{ + badges: Badge[], +}>(); + +function semanticClass(badge: Badge): string { + const style = badge.style ?? 'neutral'; + return $style[`semantic_${style}`]; +} +</script> + +<style module lang="scss"> +.badges { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--MI-margin); +} + +.badge { + display: inline-block; + border: solid 1px; + border-radius: var(--MI-radius-sm); + padding: 2px 6px; + font-size: 85%; +} + +.semantic_error { + color: var(--MI_THEME-error); + border-color: var(--MI_THEME-error); +} + +.semantic_warning { + color: var(--MI_THEME-warn); + border-color: var(--MI_THEME-warn); +} + +.semantic_success { + color: var(--MI_THEME-success); + border-color: var(--MI_THEME-success); +} +</style> diff --git a/packages/frontend/src/components/SkFollowingRecentNotes.vue b/packages/frontend/src/components/SkFollowingRecentNotes.vue index 3eb2ac8572..37f2f8833a 100644 --- a/packages/frontend/src/components/SkFollowingRecentNotes.vue +++ b/packages/frontend/src/components/SkFollowingRecentNotes.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items: notes }"> + <!-- TODO replace with SkDateSeparatedList when merged --> <MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true"> <SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/> </MkDateSeparatedList> diff --git a/packages/frontend/src/components/SkMfmWindow.vue b/packages/frontend/src/components/SkMfmWindow.vue index 14d309b7ba..c544bc528c 100644 --- a/packages/frontend/src/components/SkMfmWindow.vue +++ b/packages/frontend/src/components/SkMfmWindow.vue @@ -100,6 +100,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.unixtime }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.unixtimeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_unixtime"/> + <MkTextarea v-model="preview_unixtime"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> <div class="title">{{ i18n.ts._mfm.inlineCode }}</div> <div class="content"> <p>{{ i18n.ts._mfm.inlineCodeDescription }}</p> @@ -429,6 +439,9 @@ const preview_small = ref( const preview_center = ref( `<center>${i18n.ts._mfm.dummy}</center>`, ); +const preview_unixtime = ref( + `$[unixtime ${Math.floor(Date.now() / 1000)}]`, +); const preview_inlineCode = ref('`<: "Hello, world!"`'); const preview_blockCode = ref( '```ai\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```', diff --git a/packages/frontend/src/components/SkMutedNote.vue b/packages/frontend/src/components/SkMutedNote.vue index 3c072fab3f..c9b3d768de 100644 --- a/packages/frontend/src/components/SkMutedNote.vue +++ b/packages/frontend/src/components/SkMutedNote.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkUserName :user="note.user"/> </template> </I18n> -<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small"> +<I18n v-else-if="!prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkUserName :user="note.user"/> </template> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 99cd85d123..c4580e9156 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -155,13 +155,13 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else class="ph-smiley ph-bold ph-lg"></i> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" @mousedown.prevent="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -226,7 +226,7 @@ import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { focusPrev, focusNext } from '@/utility/focus.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; @@ -360,7 +360,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 9ca36c2fc9..8b495fb873 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -171,13 +171,13 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else class="ph-smiley ph-bold ph-lg"></i> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -283,7 +283,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; @@ -394,7 +394,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index 12e12ad028..9fa0c11d48 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="$style.noteFooterButton" :style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''" - @mousedown="renoted ? undoRenote() : boostVisibility($event.shiftKey)" + @click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)" > <i class="ph-rocket-launch ph-bold ph-lg"></i> <p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> @@ -50,24 +50,30 @@ SPDX-License-Identifier: AGPL-3.0-only ref="quoteButton" class="_button" :class="$style.noteFooterButton" - @mousedown="quote()" + @click.stop="quote()" > <i class="ph-quotes ph-bold ph-lg"></i> </button> <button v-else class="_button" :class="$style.noteFooterButton" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> + <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @click.stop="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click.stop="react()"> <i v-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> </button> <button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> <i class="ph-minus ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()"> + <i class="ti ti-paperclip"></i> + </button> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -86,10 +92,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, ref, shallowRef, watch } from 'vue'; +import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import * as config from '@@/js/config.js'; +import type { Ref } from 'vue'; import type { Visibility } from '@/utility/boost-quote.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; @@ -109,11 +116,12 @@ import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { reactionPicker } from '@/utility/reaction-picker.js'; import { claimAchievement } from '@/utility/achievements.js'; -import { getNoteMenu } from '@/utility/get-note-menu.js'; +import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; import { prefer } from '@/preferences.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; +import { instance, policies } from '@/instance'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -142,6 +150,7 @@ const translating = ref(false); const isDeleted = ref(false); const renoted = ref(false); const reactButton = shallowRef<HTMLElement>(); +const clipButton = useTemplateRef('clipButton'); const renoteButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); @@ -167,6 +176,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); +const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); + async function addReplyTo(replyNote: Misskey.entities.Note) { replies.value.unshift(replyNote); appearNote.value.repliesCount += 1; @@ -390,6 +401,14 @@ function menu(): void { os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } +async function clip(): Promise<void> { + os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); +} + +async function translate() { + await translateNote(appearNote.value.id, translation, translating); +} + if (props.detail) { misskeyApi('notes/children', { noteId: props.note.id, diff --git a/packages/frontend/src/components/SkTransitionGroup.vue b/packages/frontend/src/components/SkTransitionGroup.vue new file mode 100644 index 0000000000..1c07186501 --- /dev/null +++ b/packages/frontend/src/components/SkTransitionGroup.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup v-if="animate ?? prefer.s.animation" v-bind="props" :class="props.class"> + <slot></slot> +</TransitionGroup> +<component :is="tag" v-else :class="props.class"> + <slot></slot> +</component> +</template> + +<script setup lang="ts"> +import type { TransitionGroupProps } from 'vue'; +import { prefer } from '@/preferences'; + +// This is a "best guess" type. +// If any valid :class binding produces a type error here, then please change this to match. +type ClassBinding = string | Record<string, boolean | undefined>; + +// This can be an inline type, but pulling it out makes TS errors clearer. +interface SkTransitionGroupProps extends TransitionGroupProps { + /** + * Override CSS styles for the TransitionGroup or native element. + */ + class?: undefined | ClassBinding | ClassBinding[]; + + /** + * If true, will render a TransitionGroup. + * If false, will render a native element. + * If null or undefined (default), will respect the value of prefer.s.animation. + */ + animate?: boolean | undefined | null; +} + +const props = withDefaults(defineProps<SkTransitionGroupProps>(), { + tag: 'div', + class: undefined, + animate: undefined, +}); +</script> diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 05245716c2..73ce393113 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -5,17 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl"> - <div ref="headerEl" :class="$style.header"> + <div ref="headerEl" :class="{ [$style.header]: sticky }"> <slot name="header"></slot> </div> <div - :class="$style.body" + :class="{ [$style.body]: sticky }" :data-sticky-container-header-height="headerHeight" :data-sticky-container-footer-height="footerHeight" > <slot></slot> </div> - <div ref="footerEl" :class="$style.footer"> + <div ref="footerEl" :class="{ [$style.footer]: sticky }"> <slot name="footer"></slot> </div> </div> @@ -25,6 +25,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue'; import { DI } from '@/di.js'; +withDefaults(defineProps<{ + sticky?: boolean, +}>(), { + sticky: true, +}); + const rootEl = useTemplateRef('rootEl'); const headerEl = useTemplateRef('headerEl'); const footerEl = useTemplateRef('footerEl'); diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue index c95c74aef3..38a5c1ba23 100644 --- a/packages/frontend/src/components/global/StackingRouterView.vue +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -4,12 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_x_move : ''" +<SkTransitionGroup + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" :duration="200" tag="div" :class="$style.tabs" > @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> -</TransitionGroup> +</SkTransitionGroup> </template> <script lang="ts" setup> @@ -47,6 +47,7 @@ import { prefer } from '@/preferences.js'; import MkLoadingPage from '@/pages/_loading_.vue'; import { DI } from '@/di.js'; import { deepEqual } from '@/utility/deep-equal.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = defineProps<{ router?: Router; diff --git a/packages/frontend/src/i.ts b/packages/frontend/src/i.ts index a71ed1671f..0021298d63 100644 --- a/packages/frontend/src/i.ts +++ b/packages/frontend/src/i.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { reactive } from 'vue'; +import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { miLocalStorage } from '@/local-storage.js'; diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index e75e3dfd34..953af6333a 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, reactive } from 'vue'; +import { computed, nextTick, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js'; +import { $i } from '@/i'; // TODO: 他のタブと永続化されたstateを同期 @@ -38,6 +39,8 @@ export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFA export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); +export const policies = computed<Misskey.entities.RolePolicies>(() => $i?.policies ?? instance.policies); + export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> { if (!force) { const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; @@ -60,3 +63,8 @@ export async function fetchInstance(force = false): Promise<Misskey.entities.Met return instance; } + +// instance export can be empty sometimes, which causes problems. +await fetchInstance().catch(err => { + console.warn('Initial meta fetch failed:', err); +}); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 5c795b1f9d..f1d660a45b 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -48,23 +48,23 @@ export type Keys = ( //const safeSessionStorage = new Map<Keys, string>(); export const miLocalStorage = { - getItem: (key: Keys): string | null => { - return window.localStorage.getItem(key); + getItem: <T extends string = string>(key: Keys): T | null => { + return window.localStorage.getItem(key) as T | null; }, - setItem: (key: Keys, value: string): void => { + setItem: <T extends string = string>(key: Keys, value: T): void => { window.localStorage.setItem(key, value); }, removeItem: (key: Keys): void => { window.localStorage.removeItem(key); }, - getItemAsJson: (key: Keys): any | undefined => { + getItemAsJson: <T = any>(key: Keys): T | undefined => { const item = miLocalStorage.getItem(key); if (item === null) { return undefined; } return JSON.parse(item); }, - setItemAsJson: (key: Keys, value: any): void => { + setItemAsJson: <T = any>(key: Keys, value: T): void => { miLocalStorage.setItem(key, JSON.stringify(value)); }, }; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 5a12e3ae6d..24eb81beca 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -101,7 +101,7 @@ export const apiWithDialog = (< }); export function promiseDialog<T extends Promise<any>>( - promise: T, + promise: T | (() => T), onSuccess?: ((res: Awaited<T>) => void) | null, onFailure?: ((err: Misskey.api.APIError) => void) | null, text?: string, @@ -109,6 +109,10 @@ export function promiseDialog<T extends Promise<any>>( const showing = ref(true); const success = ref(false); + if (typeof(promise) === 'function') { + promise = promise(); + } + promise.then(res => { if (onSuccess) { showing.value = false; diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index d3c0de3040..0596b165fd 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -20,19 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only <span class="_monospace">{{ user.id }}</span> <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(user.id)"><i class="ti ti-copy"></i></button> </span> - <span class="state"> - <span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span> - <span v-if="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span> - <span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span> - <span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span> - <span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span> - </span> </div> </div> + <SkBadgeStrip v-if="badges.length > 0" :badges="badges"></SkBadgeStrip> + <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> - <MkFolder v-if="!isSystem"> + <MkFolder v-if="!isSystem" :sticky="false"> <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.details }}</template> <div style="display: flex; flex-direction: column; gap: 1em;"> @@ -89,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="info"> + <MkFolder v-if="info" :sticky="false"> <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._role.policies }}</template> <div class="_gaps"> @@ -99,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="iAmAdmin && ips && ips.length > 0"> + <MkFolder v-if="iAmAdmin && ips && ips.length > 0" :sticky="false"> <template #icon><i class="ph-network ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.ip }}</template> <MkInfo>{{ i18n.ts.ipTip }}</MkInfo> @@ -109,7 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0"> + <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0" :sticky="false"> <template #icon><i class="ph-stamp ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.moderationNote }}</template> <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> @@ -248,6 +243,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; +import type { Badge } from '@/components/SkBadgeStrip.vue'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -272,6 +268,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInput from '@/components/MkInput.vue'; import MkNumber from '@/components/MkNumber.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; const props = withDefaults(defineProps<{ userId: string; @@ -304,6 +301,98 @@ const filesPagination = { })), }; +const badges = computed(() => { + const arr: Badge[] = []; + if (info.value && user.value) { + if (info.value.isSuspended) { + arr.push({ + key: 'suspended', + label: i18n.ts.suspended, + style: 'error', + }); + } + + if (info.value.isSilenced) { + arr.push({ + key: 'silenced', + label: i18n.ts.silenced, + style: 'warning', + }); + } + + if (info.value.alwaysMarkNsfw) { + arr.push({ + key: 'nsfw', + label: i18n.ts.nsfw, + style: 'warning', + }); + } + + if (user.value.mandatoryCW) { + arr.push({ + key: 'cw', + label: i18n.ts.cw, + style: 'warning', + }); + } + + if (info.value.isHibernated) { + arr.push({ + key: 'hibernated', + label: i18n.ts.hibernated, + style: 'neutral', + }); + } + + if (info.value.isAdministrator) { + arr.push({ + key: 'admin', + label: i18n.ts.administrator, + style: 'success', + }); + } else if (info.value.isModerator) { + arr.push({ + key: 'mod', + label: i18n.ts.moderator, + style: 'success', + }); + } + + if (user.value.host == null) { + if (info.value.email) { + if (info.value.emailVerified) { + arr.push({ + key: 'verified', + label: i18n.ts.verified, + style: 'success', + }); + } else { + arr.push({ + key: 'not_verified', + label: i18n.ts.notVerified, + style: 'success', + }); + } + } + + if (info.value.approved) { + arr.push({ + key: 'approved', + label: i18n.ts.approved, + style: 'success', + }); + } else { + arr.push({ + key: 'not_approved', + label: i18n.ts.notApproved, + style: 'warning', + }); + } + } + } + return arr; +}); + const announcementsStatus = ref<'active' | 'archived'>('active'); const announcementsPagination = { @@ -323,10 +412,13 @@ function createFetcher() { userId: props.userId, }), iAmAdmin ? misskeyApi('admin/get-user-ips', { userId: props.userId, - }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { + }) : Promise.resolve(null), iAmAdmin ? misskeyApi('ap/get', { + uri: `${url}/users/${props.userId}`, + }).catch(() => null) : null]).then(([_user, _info, _ips, _ap]) => { user.value = _user; info.value = _info; ips.value = _ips; + ap.value = _ap; moderator.value = info.value.isModerator; silenced.value = info.value.isSilenced; approved.value = info.value.approved; @@ -338,23 +430,30 @@ function createFetcher() { }); } -function refreshUser() { - init.value = createFetcher(); +async function refreshUser() { + // Not a typo - createFetcher() returns a function() + await createFetcher()(); } async function onMandatoryCWChanged(value: string) { - await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: value }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/cw-user', { userId: props.userId, cw: value }); + await refreshUser(); + }); } async function onModerationNoteChanged(value: string) { - await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); + refreshUser(); + }); } async function updateRemoteUser() { - await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('federation/update-remote-user', { userId: props.userId }); + refreshUser(); + }); } async function resetPassword() { @@ -368,7 +467,7 @@ async function resetPassword() { const { password } = await misskeyApi('admin/reset-password', { userId: user.value.id, }); - os.alert({ + await os.alert({ type: 'success', text: i18n.tsx.newPasswordIs({ password }), }); @@ -383,7 +482,7 @@ async function toggleNSFW(v) { if (confirm.canceled) { markedAsNSFW.value = !v; } else { - await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: user.value.id }); + await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: props.userId }); await refreshUser(); } } @@ -396,8 +495,10 @@ async function toggleSilence(v) { if (confirm.canceled) { silenced.value = !v; } else { - await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.value.id }); - await refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: props.userId }); + await refreshUser(); + }); } } @@ -409,8 +510,10 @@ async function toggleSuspend(v) { if (confirm.canceled) { suspended.value = !v; } else { - await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.value.id }); - await refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: props.userId }); + await refreshUser(); + }); } } @@ -422,11 +525,13 @@ async function toggleRejectQuotes(v: boolean): Promise<void> { if (confirm.canceled) { rejectQuotes.value = !v; } else { - await misskeyApi('admin/reject-quotes', { - userId: props.userId, - rejectQuotes: v, + await os.promiseDialog(async () => { + await misskeyApi('admin/reject-quotes', { + userId: props.userId, + rejectQuotes: v, + }); + await refreshUser(); }); - await refreshUser(); } } @@ -436,17 +541,10 @@ async function unsetUserAvatar() { text: i18n.ts.unsetUserAvatarConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-avatar', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/unset-user-avatar', { userId: props.userId }); + await refreshUser(); }); - refreshUser(); } async function unsetUserBanner() { @@ -455,17 +553,10 @@ async function unsetUserBanner() { text: i18n.ts.unsetUserBannerConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-banner', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/unset-user-banner', { userId: props.userId }); + await refreshUser(); }); - refreshUser(); } async function deleteAllFiles() { @@ -474,17 +565,10 @@ async function deleteAllFiles() { text: i18n.ts.deleteAllFilesConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/delete-all-files-of-a-user', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/delete-all-files-of-a-user', { userId: props.userId }); + await refreshUser(); }); - await refreshUser(); } async function deleteAccount() { @@ -504,7 +588,7 @@ async function deleteAccount() { userId: user.value.id, }); } else { - os.alert({ + await os.alert({ type: 'error', text: 'input not match', }); @@ -544,18 +628,22 @@ async function assignRole() { : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) : null; - await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/roles/assign', { roleId, userId: props.userId, expiresAt }); + await refreshUser(); + }); } async function unassignRole(role, ev) { - os.popupMenu([{ + await os.popupMenu([{ text: i18n.ts.unassign, icon: 'ti ti-x', danger: true, action: async () => { - await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.value.id }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/roles/unassign', { roleId: role.id, userId: props.userId }); + await refreshUser(); + }); }, }], ev.currentTarget ?? ev.target); } @@ -591,14 +679,6 @@ watch(() => props.userId, () => { immediate: true, }); -watch(user, () => { - misskeyApi('ap/get', { - uri: user.value.uri ?? `${url}/users/${user.value.id}`, - }).then(res => { - ap.value = res; - }); -}); - const headerActions = computed(() => []); const headerTabs = computed(() => isSystem.value ? [{ @@ -782,6 +862,7 @@ definePage(() => ({ cursor: pointer; } +// Sync with instance-info.vue .buttonStrip { margin: calc(var(--MI-margin) / 2 * -1); diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index 1a80f6fef1..bc30c69f8d 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -28,12 +28,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="isSearchResult && 'toRoom' in message && message.toRoom != null" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> <MkA v-if="isSearchResult && 'toUser' in message && message.toUser != null && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> </div> - <TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''" + <SkTransitionGroup + :enterActiveClass="$style.transition_reaction_enterActive" + :leaveActiveClass="$style.transition_reaction_leaveActive" + :enterFromClass="$style.transition_reaction_enterFrom" + :leaveToClass="$style.transition_reaction_leaveTo" + :moveClass="$style.transition_reaction_move" tag="div" :class="$style.reactions" > <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="[$style.reaction, record.user.id === $i.id ? $style.reactionMy : null]" @click="onReactionClick(record)"> @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.reactionIcon" /> </div> - </TransitionGroup> + </SkTransitionGroup> </div> </div> </template> @@ -73,6 +73,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const $i = ensureSignin(); diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index e05125a3b2..6505e172dd 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -31,23 +31,23 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> </div> - <TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + <SkTransitionGroup + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" tag="div" class="_gaps" > - <template v-for="item in timeline.toReversed()" :key="item.id"> + <div v-for="item in timeline.toReversed()" :key="item.id"> <XMessage v-if="item.type === 'item'" :message="item.data"/> <div v-else-if="item.type === 'date'" :class="$style.dateDivider"> <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> </div> - </template> - </TransitionGroup> + </div> + </SkTransitionGroup> </div> <div v-if="user && (!user.canChat || user.host !== null)"> @@ -111,6 +111,7 @@ import { useRouter } from '@/router.js'; import { useMutationObserver } from '@/use/use-mutation-observer.js'; import MkInfo from '@/components/MkInfo.vue'; import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const $i = ensureSignin(); const router = useRouter(); diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index a47e3efbc8..82badd40b3 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -10,27 +10,77 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="polls">{{ i18n.ts.poll }}</option> </MkTab> <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> - <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> + <div v-else-if="tab === 'polls'"> + <template v-if="ltlAvailable || gtlAvailable"> + <MkFoldableSection v-if="ltlAvailable" class="_margin"> + <template #header><i class="ph-house ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.tsx.pollsOnLocal({ name: instance.name ?? host }) }}</template> + <MkNotes :pagination="paginationForPollsLocal" :disableAutoLoad="true"/> + </MkFoldableSection> + + <MkFoldableSection v-if="gtlAvailable" class="_margin"> + <template #header><i class="ph-globe ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pollsOnRemote }}</template> + <MkNotes :pagination="paginationForPollsRemote" :disableAutoLoad="true"/> + </MkFoldableSection> + + <MkFoldableSection v-if="gtlAvailable" class="_margin"> + <template #header><i class="ph-timer ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pollsExpired }}</template> + <MkNotes :pagination="paginationForPollsExpired" :disableAutoLoad="true"/> + </MkFoldableSection> + </template> + <template v-else> + <div v-if="$i"><i class="ti ti-alert-triangle"></i>{{ i18n.ts.trendingPollsDisabled }}</div> + <div v-else><i class="ti ti-alert-triangle"></i>{{ i18n.ts.trendingPollsDisabledLogIn }}</div> + </template> + </div> </div> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { computed, ref } from 'vue'; +import { host } from '@@/js/config.js'; import MkNotes from '@/components/MkNotes.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import { instance } from '@/instance.js'; +import { $i } from '@/i'; + +const ltlAvailable = computed(() => $i?.policies.ltlAvailable ?? instance.policies.ltlAvailable); +const gtlAvailable = computed(() => $i?.policies.gtlAvailable ?? instance.policies.gtlAvailable); const paginationForNotes = { endpoint: 'notes/featured' as const, limit: 10, }; -const paginationForPolls = { +const paginationForPollsLocal = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, + params: { + excludeChannels: true, + local: true, + }, +}; + +const paginationForPollsRemote = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, + params: { + excludeChannels: true, + local: false, + }, +}; + +const paginationForPollsExpired = { endpoint: 'notes/polls/recommendation' as const, limit: 10, offsetMode: true, params: { excludeChannels: true, + local: null, + expired: true, }, }; diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index aa18f44e88..4f74467871 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items }"> + <!-- TODO replace with SkDateSeparatedList when merged --> <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false"> <DynamicNote :key="item.id" :note="item.note" :class="$style.note"/> </MkDateSeparatedList> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 479774faef..b60bdf3a72 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -6,97 +6,129 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> <div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <MkSwiper v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'overview'" class="_gaps_m"> + <!-- This empty div is preserved to avoid merge conflicts --> + <div> + <div v-if="tab === 'overview'" class="_gaps"> <div class="fnfelxur"> - <img :src="faviconUrl" alt="" class="icon"/> - <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> - </div> - <div style="display: flex; flex-direction: column; gap: 1em;"> - <MkKeyValue :copy="host" oneline> - <template #key>Host</template> - <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.software }}</template> - <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.administrator }}</template> - <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> - </MkKeyValue> + <!-- TODO copy the alt text stuff from reports UI PR --> + <img v-if="faviconUrl" :src="faviconUrl" alt="" class="icon"/> + <div :class="$style.headerData"> + <span class="name">{{ instance.name || instance.host }}</span> + <span> + <span class="_monospace">{{ instance.host }}</span> + <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(instance.host)"><i class="ti ti-copy"></i></button> + </span> + <span> + <span class="_monospace">{{ instance.id }}</span> + <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(instance.id)"><i class="ti ti-copy"></i></button> + </span> + </div> </div> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ instance.description }}</template> - </MkKeyValue> - <FormSection v-if="iAmModerator"> - <template #label>Moderation</template> - <div class="_gaps_s"> - <MkKeyValue> - <template #key> - {{ i18n.ts._delivery.status }} - </template> - <template #value> - {{ i18n.ts._delivery._type[suspensionState] }} - </template> + <SkBadgeStrip v-if="badges.length > 0" :badges="badges"></SkBadgeStrip> + + <MkFolder :sticky="false"> + <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.details }}</template> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <MkKeyValue :copy="instance.id" oneline> + <template #key>{{ i18n.ts.id }}</template> + <template #value><span class="_monospace">{{ instance.id }}</span></template> </MkKeyValue> - <div class="_buttons"> - <MkButton inline :disabled="!instance" danger @click="deleteAllFiles">{{ i18n.ts.deleteAllFiles }}</MkButton> - <MkButton inline :disabled="!instance" danger @click="severAllFollowRelations">{{ i18n.ts.severAllFollowRelations }}</MkButton> - </div> + <MkKeyValue :copy="instance.name" oneline> + <template #key>{{ i18n.ts.name }}</template> + <template #value><span class="_monospace">{{ instance.name || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="host" oneline> + <template #key>{{ i18n.ts.host }}</template> + <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.firstRetrievedAt" oneline> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="instance.firstRetrievedAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.infoUpdatedAt" oneline> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><span class="_monospace"><MkTime :time="instance.infoUpdatedAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.latestRequestReceivedAt" oneline> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="instance.latestRequestReceivedAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.softwareName" oneline> + <template #key>{{ i18n.ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.maintainerName" oneline> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value><span class="_monospace">{{ instance.maintainerName || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.maintainerEmail" oneline> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.followingPub }}</template> + <template #value><span class="_monospace"><MkNumber :value="instance.followingCount"/></span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.followersSub }}</template> + <template #value><span class="_monospace"><MkNumber :value="instance.followersCount"/></span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts._delivery.status }}</template> + <template #value><span class="_monospace">{{ i18n.ts._delivery._type[suspensionState] }}</span></template> + </MkKeyValue> + </div> + </MkFolder> + + <MkFolder :sticky="false"> + <template #label>{{ i18n.ts.wellKnownResources }}</template> + <template #icon><i class="ph-network ph-bold ph-lg"></i></template> + <ul :class="$style.linksList" class="_gaps_s"> + <!-- TODO more links here --> + <li><MkLink :url="`https://${host}/.well-known/host-meta`" class="_monospace">/.well-known/host-meta</MkLink></li> + <li><MkLink :url="`https://${host}/.well-known/host-meta.json`" class="_monospace">/.well-known/host-meta.json</MkLink></li> + <li><MkLink :url="`https://${host}/.well-known/nodeinfo`" class="_monospace">/.well-known/nodeinfo</MkLink></li> + <li><MkLink :url="`https://${host}/robots.txt`" class="_monospace">/robots.txt</MkLink></li> + <li><MkLink :url="`https://${host}/manifest.json`" class="_monospace">/manifest.json</MkLink></li> + </ul> + </MkFolder> + + <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0" :sticky="false"> + <template #icon><i class="ph-stamp ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.moderationNote }}</template> + <MkTextarea v-model="moderationNote" manualSave @update:modelValue="saveModerationNote"> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> + </MkFolder> + + <FormSection v-if="instance.description"> + <template #label>{{ i18n.ts.description }}</template> + {{ instance.description }} + </FormSection> + + <FormSection v-if="iAmModerator"> + <template #label>{{ i18n.ts.moderation }}</template> + <div class="_gaps"> + <MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo> + <MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkSwitch v-model="isSuspended" :disabled="!instance" @update:modelValue="toggleSuspended">{{ i18n.ts._delivery.stop }}</MkSwitch> <MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo> <MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> - <MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo> - <MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> - <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch> <MkSwitch v-model="rejectQuotes" :disabled="!instance" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesInstance }}</MkSwitch> + <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch> <MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch> <MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo> <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch> - <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> - <MkTextarea v-model="moderationNote" manualSave> - <template #label>{{ i18n.ts.moderationNote }}</template> - <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> - </MkTextarea> - </div> - </FormSection> - - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.registeredAt }}</template> - <template #value><MkTime mode="detail" :time="instance.firstRetrievedAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.updatedAt }}</template> - <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> - <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> - </MkKeyValue> - </FormSection> - - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Following (Pub)</template> - <template #value>{{ number(instance.followingCount) }}</template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Followers (Sub)</template> - <template #value>{{ number(instance.followersCount) }}</template> - </MkKeyValue> - </FormSection> - <FormSection> - <template #label>Well-known resources</template> - <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> - <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> - <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> - <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> - <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> + <div :class="$style.buttonStrip"> + <MkButton inline :disabled="!instance" @click="refreshMetadata"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton> + <MkButton inline :disabled="!instance" danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton> + <MkButton inline :disabled="!instance" danger @click="severAllFollowRelations"><i class="ph-link-break ph-bold ph-lg"></i> {{ i18n.ts.severAllFollowRelations }}</MkButton> + </div> + </div> </FormSection> </div> <div v-else-if="tab === 'chart'" class="_gaps_m"> @@ -126,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'users'" class="_gaps_m"> <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> - <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(user.updatedAt) })" class="user" :to="`/admin/user/${user.id}`"> <MkUserCardMini :user="user"/> </MkA> </MkPagination> @@ -135,11 +167,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :pagination="followingPagination"> <div class="follow-relations-list"> <div v-for="followRelationship in items" :key="followRelationship.id" class="follow-relation"> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.followee.updatedAt)}`" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.followee.updatedAt) })" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> <MkUserCardMini :user="followRelationship.followee" :withChart="false"/> </MkA> <span class="arrow">→</span> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.follower.updatedAt)}`" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.follower.updatedAt) })" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> <MkUserCardMini :user="followRelationship.follower" :withChart="false"/> </MkA> </div> @@ -150,11 +182,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :pagination="followersPagination"> <div class="follow-relations-list"> <div v-for="followRelationship in items" :key="followRelationship.id" class="follow-relation"> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.followee.updatedAt)}`" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.followee.updatedAt) })" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> <MkUserCardMini :user="followRelationship.followee" :withChart="false"/> </MkA> <span class="arrow">←</span> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.follower.updatedAt)}`" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.follower.updatedAt) })" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> <MkUserCardMini :user="followRelationship.follower" :withChart="false"/> </MkA> </div> @@ -165,16 +197,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkObjectView tall :value="instance"> </MkObjectView> </div> - </MkSwiper> + </div> </div> </PageWithHeader> </template> <script lang="ts" setup> -import { ref, computed, watch } from 'vue'; +import { ref, computed, watch, useCssModule } from 'vue'; import * as Misskey from 'misskey-js'; import type { ChartSrc } from '@/components/MkChart.vue'; import type { Paging } from '@/components/MkPagination.vue'; +import type { Badge } from '@/components/SkBadgeStrip.vue'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; @@ -197,6 +230,13 @@ import { dateString } from '@/filters/date.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import { $i } from '@/i.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import { acct } from '@/filters/user'; +import MkFolder from '@/components/MkFolder.vue'; +import MkNumber from '@/components/MkNumber.vue'; +import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; + +const $style = useCssModule(); const props = defineProps<{ host: string; @@ -233,6 +273,55 @@ const isBaseBlocked = computed(() => meta.value && baseDomains.value.some(d => m const isBaseSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.silencedHosts.includes(d))); const isBaseMediaSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.mediaSilencedHosts.includes(d))); +const badges = computed(() => { + const arr: Badge[] = []; + if (instance.value) { + if (instance.value.isBlocked) { + arr.push({ + key: 'blocked', + label: i18n.ts.blocked, + style: 'error', + }); + } + if (instance.value.isSuspended) { + arr.push({ + key: 'suspended', + label: i18n.ts.suspended, + style: 'error', + }); + } + if (instance.value.isSilenced) { + arr.push({ + key: 'silenced', + label: i18n.ts.silenced, + style: 'warning', + }); + } + if (instance.value.isMediaSilenced) { + arr.push({ + key: 'media_silenced', + label: i18n.ts.mediaSilenced, + style: 'warning', + }); + } + if (instance.value.isNSFW) { + arr.push({ + key: 'nsfw', + label: i18n.ts.nsfw, + style: 'warning', + }); + } + if (instance.value.isBubbled) { + arr.push({ + key: 'bubbled', + label: i18n.ts.bubble, + style: 'success', + }); + } + } + return arr; +}); + const usersPagination = { endpoint: iAmModerator ? 'admin/show-users' : 'users', limit: 10, @@ -264,20 +353,26 @@ const followersPagination = { offsetMode: false, }; -if (iAmModerator) { - watch(moderationNote, async () => { - if (instance.value == null) return; - await misskeyApi('admin/federation/update-instance', { host: instance.value.host, moderationNote: moderationNote.value }); - }); +async function saveModerationNote() { + if (iAmModerator) { + await os.promiseDialog(async () => { + if (instance.value == null) return; + await os.apiWithDialog('admin/federation/update-instance', { host: instance.value.host, moderationNote: moderationNote.value }); + await fetch(); + }); + } } async function fetch(): Promise<void> { - if (iAmAdmin) { - meta.value = await misskeyApi('admin/meta'); - } - instance.value = await misskeyApi('federation/show-instance', { - host: props.host, - }); + const [m, i] = await Promise.all([ + iAmAdmin ? misskeyApi('admin/meta') : null, + misskeyApi('federation/show-instance', { + host: props.host, + }), + ]); + meta.value = m; + instance.value = i; + suspensionState.value = instance.value?.suspensionState ?? 'none'; isSuspended.value = suspensionState.value !== 'none'; isBlocked.value = instance.value?.isBlocked ?? false; @@ -292,80 +387,106 @@ async function fetch(): Promise<void> { async function toggleBlock(): Promise<void> { if (!iAmAdmin) return; - if (!meta.value) throw new Error('No meta?'); - if (!instance.value) throw new Error('No instance?'); - const { host } = instance.value; - await misskeyApi('admin/update-meta', { - blockedHosts: isBlocked.value ? meta.value.blockedHosts.concat([host]) : meta.value.blockedHosts.filter(x => x !== host), + await os.promiseDialog(async () => { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + await os.apiWithDialog('admin/update-meta', { + blockedHosts: isBlocked.value ? meta.value.blockedHosts.concat([host]) : meta.value.blockedHosts.filter(x => x !== host), + }); + await fetch(); }); } async function toggleSilenced(): Promise<void> { if (!iAmAdmin) return; - if (!meta.value) throw new Error('No meta?'); - if (!instance.value) throw new Error('No instance?'); - const { host } = instance.value; - const silencedHosts = meta.value.silencedHosts ?? []; - await misskeyApi('admin/update-meta', { - silencedHosts: isSilenced.value ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host), + await os.promiseDialog(async () => { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + const silencedHosts = meta.value.silencedHosts ?? []; + await os.promiseDialog(async () => { + await misskeyApi('admin/update-meta', { + silencedHosts: isSilenced.value ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host), + }); + await fetch(); + }); }); } async function toggleMediaSilenced(): Promise<void> { if (!iAmAdmin) return; - if (!meta.value) throw new Error('No meta?'); - if (!instance.value) throw new Error('No instance?'); - const { host } = instance.value; - const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? []; - await misskeyApi('admin/update-meta', { - mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host), + await os.promiseDialog(async () => { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? []; + await misskeyApi('admin/update-meta', { + mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host), + }); + await fetch(); }); } async function toggleSuspended(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - suspensionState.value = isSuspended.value ? 'manuallySuspended' : 'none'; - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - isSuspended: isSuspended.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + suspensionState.value = isSuspended.value ? 'manuallySuspended' : 'none'; + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + isSuspended: isSuspended.value, + }); + await fetch(); }); } async function toggleNSFW(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - isNSFW: isNSFW.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + isNSFW: isNSFW.value, + }); + await fetch(); }); } async function toggleRejectReports(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - rejectReports: rejectReports.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + rejectReports: rejectReports.value, + }); + await fetch(); }); } async function toggleRejectQuotes(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - rejectQuotes: rejectQuotes.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + rejectQuotes: rejectQuotes.value, + }); + await fetch(); }); } -function refreshMetadata(): void { +async function refreshMetadata(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - misskeyApi('admin/federation/refresh-remote-instance-metadata', { - host: instance.value.host, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/refresh-remote-instance-metadata', { + host: instance.value.host, + }); + await fetch(); }); - os.alert({ + await os.alert({ text: 'Refresh requested', }); } @@ -380,14 +501,12 @@ async function deleteAllFiles(): Promise<void> { }); if (confirm.canceled) return; - await Promise.all([ - misskeyApi('admin/federation/delete-all-files', { - host: instance.value.host, - }), - os.alert({ - text: i18n.ts.deleteAllFilesQueued, - }), - ]); + await os.apiWithDialog('admin/federation/delete-all-files', { + host: instance.value.host, + }); + await os.alert({ + text: i18n.ts.deleteAllFilesQueued, + }); } async function severAllFollowRelations(): Promise<void> { @@ -404,14 +523,12 @@ async function severAllFollowRelations(): Promise<void> { }); if (confirm.canceled) return; - await Promise.all([ - misskeyApi('admin/federation/remove-all-following', { - host: instance.value.host, - }), - os.alert({ - text: i18n.tsx.severAllFollowRelationsQueued({ host: instance.value.host }), - }), - ]); + await os.apiWithDialog('admin/federation/remove-all-following', { + host: instance.value.host, + }); + await os.alert({ + text: i18n.tsx.severAllFollowRelationsQueued({ host: instance.value.host }), + }); } fetch(); @@ -429,16 +546,16 @@ const headerTabs = computed(() => [{ title: i18n.ts.overview, icon: 'ti ti-info-circle', }, { - key: 'chart', - title: i18n.ts.charts, - icon: 'ti ti-chart-line', -}, { key: 'users', title: i18n.ts.users, icon: 'ti ti-users', }, ...getFollowingTabs(), { + key: 'chart', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}, { key: 'raw', - title: 'Raw', + title: i18n.ts.raw, icon: 'ti ti-code', }]); @@ -522,3 +639,38 @@ definePage(() => ({ } } </style> + +<style lang="scss" module> +.headerData { + display: flex; + flex-direction: column; + + > * { + overflow: hidden; + text-overflow: ellipsis; + font-size: 85%; + opacity: 0.7; + } + + > :first-child { + text-overflow: initial; + word-break: break-all; + font-size: 100%; + opacity: 1.0; + } +} + +.linksList { + margin: 0; + padding-left: 1.5em; +} + +// Sync with admin-user.vue +.buttonStrip { + margin: calc(var(--MI-margin) / 2 * -1); + + >* { + margin: calc(var(--MI-margin) / 2); + } +} +</style> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 59b1a5a137..f4d0f25734 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -347,7 +347,7 @@ definePage(() => ({ text-align: center; border-radius: 99rem; - & :global(.ti) { + & :global(.ti), & :global(.ph-lg) { line-height: 2.5rem; } diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index 9b0e04860e..07b16d5c45 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -22,23 +22,24 @@ import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; +import { reloadAsk } from '@/utility/reload-ask'; -const localCustomCss = ref(miLocalStorage.getItem('customCss') ?? ''); +const customCssModel = prefer.model('customCss'); +const localCustomCss = computed<string>({ + get() { + return customCssModel.value ?? miLocalStorage.getItem('customCss') ?? ''; + }, + set(newCustomCss) { + customCssModel.value = newCustomCss; + if (newCustomCss) { + miLocalStorage.setItem('customCss', newCustomCss); + } else { + miLocalStorage.removeItem('customCss'); + } -async function apply() { - miLocalStorage.setItem('customCss', localCustomCss.value); - - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - -watch(localCustomCss, async () => { - await apply(); + reloadAsk(true); + }, }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 8cc3945df8..e19d7eff85 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -12,10 +12,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <SearchMarker + v-slot="slotProps" :label="i18n.ts.wordMute" :keywords="['note', 'word', 'soft', 'mute', 'hide']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.wordMute }}</template> @@ -37,10 +38,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.hardWordMute" :keywords="['note', 'word', 'hard', 'mute', 'hide']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ph-x-square ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.hardWordMute }}</template> @@ -55,10 +57,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.instanceMute" :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" > - <MkFolder v-if="instance.federation !== 'none'"> + <MkFolder v-if="instance.federation !== 'none'" :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-planet-off"></i></template> <template #label>{{ i18n.ts.instanceMute }}</template> @@ -67,9 +70,10 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :keywords="['renote', 'mute', 'hide', 'user']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-repeat-off"></i></template> <template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template> @@ -102,10 +106,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.mutedUsers" :keywords="['note', 'mute', 'hide', 'user']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-eye-off"></i></template> <template #label>{{ i18n.ts.mutedUsers }}</template> @@ -140,10 +145,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.blockedUsers" :keywords="['block', 'user']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-ban"></i></template> <template #label>{{ i18n.ts.blockedUsers }}</template> @@ -223,12 +229,6 @@ const expandedBlockItems = ref([]); const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); -watch([ - showSoftWordMutedWord, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - async function unrenoteMute(user, ev) { os.popupMenu([{ text: i18n.ts.renoteUnmute, diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index a537b6e837..4e7568561b 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> </div> - <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']"> + <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji', 'tossface']"> <MkPreferenceContainer k="emojiStyle"> <div> <MkRadios v-model="emojiStyle"> @@ -107,6 +107,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="native">{{ i18n.ts.native }}</option> <option value="fluentEmoji">Fluent Emoji</option> <option value="twemoji">Twemoji</option> + <option value="tossface">Tossface</option> </MkRadios> <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> </div> @@ -196,8 +197,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['pinned', 'list']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['pinned', 'list']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> <MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton> @@ -237,6 +238,13 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- If one of the other options is selected show this as a blank other --> <option v-if="!useCustomSearchEngine" value="">{{ i18n.ts.searchEngineOther }}</option> </MkSelect> + + <div v-if="useCustomSearchEngine"> + <MkInput v-model="searchEngine" :max="300" :manualSave="true"> + <template #label>{{ i18n.ts.searchEngineCusomURI }}</template> + <template #caption>{{ i18n.ts.searchEngineCustomURIDescription }}</template> + </MkInput> + </div> </MkPreferenceContainer> </SearchMarker> @@ -436,9 +444,9 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> </div> - <SearchMarker :keywords="['default', 'note', 'visibility']"> + <SearchMarker v-slot="slotProps" :keywords="['default', 'note', 'visibility']"> <MkDisableSection :disabled="rememberNoteVisibility"> - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template> <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> @@ -675,7 +683,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['font', 'size']"> <MkRadios v-model="fontSize"> <template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template> - <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="0"><span style="font-size: 14px;">Aa</span></option> <option value="1"><span style="font-size: 15px;">Aa</span></option> <option value="2"><span style="font-size: 16px;">Aa</span></option> <option value="3"><span style="font-size: 17px;">Aa</span></option> @@ -787,7 +795,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['corner', 'radius']"> <MkRadios v-model="cornerRadius"> <template #label><SearchLabel>{{ i18n.ts.cornerRadius }}</SearchLabel></template> - <option :value="null"><i class="sk-icons sk-shark sk-icons-lg" style="top: 2px;position: relative;"></i> Sharkey</option> + <option value="sharkey"><i class="sk-icons sk-shark sk-icons-lg" style="top: 2px;position: relative;"></i> Sharkey</option> <option value="misskey"><i class="sk-icons sk-misskey sk-icons-lg" style="top: 2px;position: relative;"></i> Misskey</option> </MkRadios> </SearchMarker> @@ -850,24 +858,28 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['boost', 'show', 'visib', 'selector']"> + <SearchMarker v-slot="slotProps" :keywords="['boost', 'show', 'visib', 'selector']"> <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.boostSettings }}</SearchLabel></template> <div class="_gaps_m"> - <MkPreferenceContainer k="showVisibilitySelectorOnBoost"> - <MkSwitch v-model="showVisibilitySelectorOnBoost"> - <template #label><SearchLabel>{{ i18n.ts.showVisibilitySelectorOnBoost }}</SearchLabel></template> - <template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template> - </MkSwitch> - </MkPreferenceContainer> - <MkPreferenceContainer k="visibilityOnBoost"> - <MkSelect v-model="visibilityOnBoost"> - <template #label><SearchLabel>{{ i18n.ts.visibilityOnBoost }}</SearchLabel></template> - <option value="public">{{ i18n.ts._visibility['public'] }}</option> - <option value="home">{{ i18n.ts._visibility['home'] }}</option> - <option value="followers">{{ i18n.ts._visibility['followers'] }}</option> - </MkSelect> - </MkPreferenceContainer> + <SearchMarker :keywords="['boost', 'show', 'visib', 'selector']"> + <MkPreferenceContainer k="showVisibilitySelectorOnBoost"> + <MkSwitch v-model="showVisibilitySelectorOnBoost"> + <template #label><SearchLabel>{{ i18n.ts.showVisibilitySelectorOnBoost }}</SearchLabel></template> + <template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['boost', 'visib']"> + <MkPreferenceContainer k="visibilityOnBoost"> + <MkSelect v-model="visibilityOnBoost"> + <template #label><SearchLabel>{{ i18n.ts.visibilityOnBoost }}</SearchLabel></template> + <option value="public">{{ i18n.ts._visibility['public'] }}</option> + <option value="home">{{ i18n.ts._visibility['home'] }}</option> + <option value="followers">{{ i18n.ts._visibility['followers'] }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> </div> </MkFolder> </SearchMarker> @@ -899,8 +911,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['emoji', 'dictionary', 'additional', 'extra']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template> <div class="_buttons"> <template v-for="lang in emojiIndexLangs" :key="lang"> @@ -962,7 +974,6 @@ import { worksOnInstance } from '@/utility/favicon-dot.js'; const $i = ensureSignin(); -const lang = ref(miLocalStorage.getItem('lang')); const dataSaver = ref(prefer.s.dataSaver); const overridedDeviceKind = prefer.model('overridedDeviceKind'); @@ -1022,9 +1033,6 @@ const contextMenu = prefer.model('contextMenu'); const menuStyle = prefer.model('menuStyle'); const makeEveryTextElementsSelectable = prefer.model('makeEveryTextElementsSelectable'); -const fontSize = ref(miLocalStorage.getItem('fontSize')); -const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); - // Sharkey options const collapseNotesRepliedTo = prefer.model('collapseNotesRepliedTo'); const showTickerOnReplies = prefer.model('showTickerOnReplies'); @@ -1040,7 +1048,6 @@ const notificationClickable = prefer.model('notificationClickable'); const warnExternalUrl = prefer.model('warnExternalUrl'); const showVisibilitySelectorOnBoost = prefer.model('showVisibilitySelectorOnBoost'); const visibilityOnBoost = prefer.model('visibilityOnBoost'); -const cornerRadius = ref(miLocalStorage.getItem('cornerRadius')); const oneko = prefer.model('oneko'); const numberOfReplies = prefer.model('numberOfReplies'); const autoloadConversation = prefer.model('autoloadConversation'); @@ -1049,34 +1056,62 @@ const useCustomSearchEngine = computed(() => !Object.keys(searchEngineMap).inclu const defaultCW = ref($i.defaultCW); const defaultCWPriority = ref($i.defaultCWPriority); -watch(lang, () => { - miLocalStorage.setItem('lang', lang.value as string); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); +const langModel = prefer.model('lang'); +const lang = computed<string>({ + get() { + return langModel.value ?? miLocalStorage.getItem('lang') ?? 'en-US'; + }, + set(newLang) { + langModel.value = newLang; + miLocalStorage.setItem('lang', newLang); + miLocalStorage.removeItem('locale'); + miLocalStorage.removeItem('localeVersion'); + }, }); -watch(fontSize, () => { - if (fontSize.value == null) { - miLocalStorage.removeItem('fontSize'); - } else { - miLocalStorage.setItem('fontSize', fontSize.value); - } +const fontSizeModel = prefer.model('fontSize'); +const fontSize = computed<'0' | '1' | '2' | '3'>({ + get() { + return fontSizeModel.value ?? miLocalStorage.getItem('fontSize') ?? '0'; + }, + set(newFontSize) { + fontSizeModel.value = newFontSize; + if (newFontSize !== '0') { + miLocalStorage.setItem('fontSize', newFontSize); + } else { + miLocalStorage.removeItem('fontSize'); + } + }, }); -watch(useSystemFont, () => { - if (useSystemFont.value) { - miLocalStorage.setItem('useSystemFont', 't'); - } else { - miLocalStorage.removeItem('useSystemFont'); - } +const useSystemFontModel = prefer.model('useSystemFont'); +const useSystemFont = computed<boolean>({ + get() { + return useSystemFontModel.value ?? (miLocalStorage.getItem('useSystemFont') != null); + }, + set(newUseSystemFont) { + useSystemFontModel.value = newUseSystemFont; + if (newUseSystemFont) { + miLocalStorage.setItem('useSystemFont', 't'); + } else { + miLocalStorage.removeItem('useSystemFont'); + } + }, }); -watch(cornerRadius, () => { - if (cornerRadius.value == null) { - miLocalStorage.removeItem('cornerRadius'); - } else { - miLocalStorage.setItem('cornerRadius', cornerRadius.value); - } +const cornerRadiusModel = prefer.model('cornerRadius'); +const cornerRadius = computed<'misskey' | 'sharkey'>({ + get() { + return cornerRadiusModel.value ?? miLocalStorage.getItem('cornerRadius') ?? 'sharkey'; + }, + set(newCornerRadius) { + cornerRadiusModel.value = newCornerRadius; + if (newCornerRadius === 'sharkey') { + miLocalStorage.removeItem('cornerRadius'); + } else { + miLocalStorage.setItem('cornerRadius', newCornerRadius); + } + }, }); watch([ @@ -1105,7 +1140,9 @@ watch([ contextMenu, fontSize, useSystemFont, + cornerRadius, makeEveryTextElementsSelectable, + noteDesign, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 4d196dbb8f..f25e59b1ca 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -203,19 +203,21 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSlot> </SearchMarker> - <SearchMarker :keywords="['federate', 'auth', 'fetch']"> - <MkFolder v-if="instance.federation !== 'none'"> + <SearchMarker v-slot="slotProps" :keywords="['federate', 'auth', 'fetch']"> + <MkFolder v-if="instance.federation !== 'none'" :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.authorizedFetchSection }}</SearchLabel></template> <template #suffix>{{ computedAllowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled }}</template> - <MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()"> - <template #label><SearchLabel>{{ i18n.ts.authorizedFetchLabel }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.authorizedFetchDescription }}</SearchKeyword></template> - <option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option> - <option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option> - <option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option> - <option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option> - </MkRadios> + <SearchMarker :keywords="['federate', 'auth', 'fetch']"> + <MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts.authorizedFetchLabel }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.authorizedFetchDescription }}</SearchKeyword></template> + <option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option> + <option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option> + <option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option> + <option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option> + </MkRadios> + </SearchMarker> </MkFolder> </SearchMarker> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index be4ef5a867..21bc74326a 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -71,9 +71,9 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSelect> </SearchMarker> - <SearchMarker :keywords="['metadata']"> + <SearchMarker v-slot="slotProps" :keywords="['metadata']"> <FormSlot> - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-list"></i></template> <template #label><SearchLabel>{{ i18n.ts._profile.metadataEdit }}</SearchLabel></template> <template #footer> @@ -139,8 +139,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSelect> </SearchMarker> - <SearchMarker> - <MkFolder> + <SearchMarker v-slot="slotProps"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.advancedSettings }}</SearchLabel></template> <div class="_gaps_m"> @@ -149,6 +149,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><SearchLabel>{{ i18n.ts.flagAsCat }}</SearchLabel></template> <template #caption>{{ i18n.ts.flagAsCatDescription }}</template> </MkSwitch> + </SearchMarker> + <SearchMarker :keywords="['cat']"> <MkSwitch v-if="profile.isCat" v-model="profile.speakAsCat"> <template #label><SearchLabel>{{ i18n.ts.flagSpeakAsCat }}</SearchLabel></template> <template #caption>{{ i18n.ts.flagSpeakAsCatDescription }}</template> diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index a4d52c8acb..22059041d1 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -477,4 +477,32 @@ export const PREF_DEF = { default: true, }, //#endregion + + //#region hybrid options + // These exist in preferences, but may have a legacy value in local storage. + // Some parts of the system may still reference the legacy storage so both need to stay in sync! + // Null means "fall back to existing value from localStorage" + // For all of these preferences, "null" means fall back to existing value in localStorage. + fontSize: { + default: null as null | '0' | '1' | '2' | '3', + }, + useSystemFont: { + default: null as null | boolean, + }, + cornerRadius: { + default: null as null | 'misskey' | 'sharkey', + }, + lang: { + default: null as null | string, + }, + customCss: { + default: null as null | string, + }, + neverShowDonationInfo: { + default: null as null | 'true', + }, + neverShowLocalOnlyInfo: { + default: null as null | 'true', + }, + //#endregion } satisfies PreferencesDefinition; diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 18e67eaa95..7b2638903b 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -235,6 +235,12 @@ rt { contain: strict; overflow: auto; overscroll-behavior: contain; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } ._pageScrollable { diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 7cfbd9df0a..d080f48696 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -67,8 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XUpload v-if="uploads.length > 0"/> -<component - :is="prefer.s.animation ? TransitionGroup : 'div'" +<SkTransitionGroup tag="div" :class="[$style.notifications, { [$style.notificationsPosition_leftTop]: prefer.s.notificationPosition === 'leftTop', @@ -87,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-for="notification in notifications" :key="notification.id" :class="$style.notification" :style="{ pointerEvents: getPointerEvents() }"> <XNotification :notification="notification"/> </div> -</component> +</SkTransitionGroup> <XStreamIndicator/> @@ -115,6 +114,7 @@ import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 6bf0dfc17c..f0c62aa8e2 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <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.sidebarLogoUrl && !iconOnly ? instance.sidebarLogoUrl : instance.iconUrl || '/favicon.ico'" alt="" :class="instance.sidebarLogoUrl && !iconOnly ? $style.wideInstanceIcon : $style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> @@ -299,6 +300,18 @@ function menuEdit() { backdrop-filter: var(--MI-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%); + } + .instance { position: relative; display: block; diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index f773149fac..2f9b613725 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -11,7 +11,7 @@ import type { Ref, ShallowRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; +import { instance, policies } from '@/instance.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; @@ -342,7 +342,7 @@ export function getNoteMenu(props: { }); } - if ($i.policies.canUseTranslator && instance.translatorAvailable) { + if (policies.value.canUseTranslator && instance.translatorAvailable) { menuItems.push({ icon: 'ti ti-language-hiragana', text: i18n.ts.translate, @@ -497,6 +497,14 @@ export function getNoteMenu(props: { } else { menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.embed)); } + + if (policies.value.canUseTranslator && instance.translatorAvailable) { + menuItems.push({ + icon: 'ti ti-language-hiragana', + text: i18n.ts.translate, + action: () => translateNote(appearNote.id, props.translation, props.translating), + }); + } } const noteActions = getPluginHandlers('note_action'); diff --git a/packages/frontend/src/utility/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts index 7c7ea113d4..f49de80231 100644 --- a/packages/frontend/src/utility/reload-ask.ts +++ b/packages/frontend/src/utility/reload-ask.ts @@ -12,6 +12,10 @@ let isReloadConfirming = false; export async function reloadAsk(opts: { unison?: boolean; reason?: string; + type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string; + okText?: string; + cancelText?: string; }) { if (isReloadConfirming) { return; @@ -19,13 +23,12 @@ export async function reloadAsk(opts: { isReloadConfirming = true; - const { canceled } = await os.confirm(opts.reason == null ? { - type: 'info', - text: i18n.ts.reloadConfirm, - } : { - type: 'info', - title: i18n.ts.reloadConfirm, - text: opts.reason, + const { canceled } = await os.confirm({ + type: opts.type ?? 'question', + title: opts.title ?? i18n.ts.reloadConfirm, + text: opts.reason ?? undefined, + okText: opts.okText ?? i18n.ts.yes, + cancelText: opts.cancelText ?? i18n.ts.no, }).finally(() => { isReloadConfirming = false; }); diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index d983376566..799225662b 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwalb"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="instances"> + <SkTransitionGroup v-else tag="div" name="chart" class="instances"> <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> <img :src="getInstanceIcon(instance)" alt=""/> <div class="body"> @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkMiniChart class="chart" :src="charts[i].requests.received"/> </div> - </TransitionGroup> + </SkTransitionGroup> </div> </MkContainer> </template> @@ -37,6 +37,7 @@ import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { prefer } from '@/preferences.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const name = 'federation'; diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index db09031c33..d5e2c930de 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwala"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="tags"> + <SkTransitionGroup v-else tag="div" name="chart" class="tags"> <div v-for="stat in stats" :key="stat.tag"> <div class="tag"> <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkMiniChart class="chart" :src="stat.chart"/> </div> - </TransitionGroup> + </SkTransitionGroup> </div> </MkContainer> </template> @@ -35,6 +35,7 @@ import MkMiniChart from '@/components/MkMiniChart.vue'; import { misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const name = 'hashtags'; diff --git a/packages/frontend/src/workers/tsconfig.json b/packages/frontend/src/workers/tsconfig.json index 8ee8930465..39ba45ddbb 100644 --- a/packages/frontend/src/workers/tsconfig.json +++ b/packages/frontend/src/workers/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { "lib": ["esnext", "webworker"], + "incremental": true } } diff --git a/packages/frontend/test/tsconfig.json b/packages/frontend/test/tsconfig.json index 98ac45211b..1490a66d20 100644 --- a/packages/frontend/test/tsconfig.json +++ b/packages/frontend/test/tsconfig.json @@ -22,6 +22,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "baseUrl": "./", "paths": { "@/*": ["../src/*"] diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 3c7e5e1da3..0616eee5be 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -23,6 +23,7 @@ "useDefineForClassFields": true, "verbatimModuleSyntax": true, "skipLibCheck": true, + "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/packages/frontend/vite.replaceIcons.ts b/packages/frontend/vite.replaceIcons.ts index ce05fc7e7b..36e6666925 100644 --- a/packages/frontend/vite.replaceIcons.ts +++ b/packages/frontend/vite.replaceIcons.ts @@ -224,6 +224,7 @@ export function pluginReplaceIcons() { 'ti ti-dice-5': 'ph ph-dice-five ph-bold ph-lg', 'ti ti-dots': 'ph-dots-three ph-bold ph-lg', 'ti ti-download': 'ph-download ph-bold ph-lg', + 'ti-download': 'ph-download ph-bold ph-lg', // in custom-emoji-manager.remote.list 'ti ti-edit': 'ph-pencil-simple-line ph-bold ph-lg', 'ti ti-equal-double': 'ph-equals ph-bold ph-lg', 'ti ti-equal-not': 'ph-prohibit ph-bold ph-lg', @@ -258,6 +259,7 @@ export function pluginReplaceIcons() { 'ti ti-home': 'ph-house ph-bold ph-lg', 'ti ti-hourglass-empty': 'ph-hourglass ph-bold ph-lg', 'ti ti-icons': 'ph-squares-four ph-bold ph-lg', + 'ti-icons': 'ph-squares-four ph-bold ph-lg', // in custom-emoji-manager.local.list 'ti ti-id': 'ph-identification-card ph-bold ph-lg', 'ti ti-info-circle': 'ph-info ph-bold ph-lg', 'ti ti-json': 'ph-brackets-curly ph-bold ph-lg', @@ -275,6 +277,7 @@ export function pluginReplaceIcons() { 'ti ti-lock-star': 'ph-shield-star ph-bold ph-lg', 'ti ti-login-2': 'ph-sign-in ph-bold ph-lg', 'ti ti-mail': 'ph-envelope ph-bold ph-lg', + 'ti-mail': 'ph-envelope ph-bold ph-lg', // in notification-recipient.item.vue 'ti ti-map-pin': 'ph-map-pin ph-bold ph-lg', 'ti ti-maximize': 'ph-frame-corners ph-bold ph-lg', 'ti ti-medal': 'ph-trophy ph-bold ph-lg', @@ -359,6 +362,7 @@ export function pluginReplaceIcons() { 'ti ti-text-caption': 'ph-text-indent ph-bold ph-lg', 'ti ti-tool': 'ph-wrench ph-bold ph-lg', 'ti ti-trash': 'ph-trash ph-bold ph-lg', + 'ti-trash': 'ph-trash ph-bold ph-lg', // in custom-emoji-manager.local.list 'ti ti-trophy': 'ph-trophy ph-bold ph-lg', 'ti ti-universe': 'ph-rocket-launch ph-bold ph-lg', 'ti ti-upload': 'ph-upload ph-bold ph-lg', @@ -379,6 +383,7 @@ export function pluginReplaceIcons() { 'ti ti-volume': 'ph-speaker-high ph-bold ph-lg', 'ti ti-volume-3': 'ph-speaker-x ph-bold ph-lg', 'ti ti-webhook': 'ph-webhooks-logo ph-bold ph-lg', + 'ti-webhook': 'ph-webhooks-logo ph-bold ph-lg', // in notification-recipient.item.vue 'ti ti-whirl': 'ph-globe-hemisphere-west ph-bold ph-lg', 'ti ti-window-maximize': 'ph-frame-corners ph-bold ph-lg', 'ti ti-world': 'ph-globe-hemisphere-west ph-bold ph-lg', @@ -389,6 +394,7 @@ export function pluginReplaceIcons() { 'ti ti-world-x': 'ph-planet ph-bold ph-lg', 'ti ti-x': 'ph-x ph-bold ph-lg', 'ti ti-help': 'ph-question ph-bold ph-lg', + 'ti-help': 'ph-question ph-bold ph-lg', // in notification-recipient.item.vue 'ti ti ti-caret-down': 'ph-caret-down ph-bold ph-lg', 'ti ti-chevron-down': 'ph-caret-down ph-bold ph-lg', 'ti ti-accessible': 'ph-person-simple-circle ph-bold ph-lg', diff --git a/packages/misskey-bubble-game/tsconfig.json b/packages/misskey-bubble-game/tsconfig.json index f467951ef6..3cf8bb037f 100644 --- a/packages/misskey-bubble-game/tsconfig.json +++ b/packages/misskey-bubble-game/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitReturns": true, "esModuleInterop": true, "skipLibCheck": true, + "incremental": true, "typeRoots": [ "./node_modules/@types" ], diff --git a/packages/misskey-js/generator/tsconfig.json b/packages/misskey-js/generator/tsconfig.json index d65042dc6d..0de2a7fe77 100644 --- a/packages/misskey-js/generator/tsconfig.json +++ b/packages/misskey-js/generator/tsconfig.json @@ -8,6 +8,7 @@ "strictFunctionTypes": true, "strictNullChecks": true, "esModuleInterop": true, + "incremental": true, "lib": [ "esnext", ] diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index d6073c9861..7b52f02178 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.4.2-rc", + "version": "2025.5.0-dev", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 0dfe042811..8827fe9c39 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3840,7 +3840,7 @@ declare module '../api.js' { /** * No description provided. * - * **Credential required**: *Yes* / **Permission**: *read:account* + * **Credential required**: *No* */ request<E extends 'notes/polls/recommendation', P extends Endpoints[E]['req']>( endpoint: E, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index e5dc28fd9b..c0694ebb96 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3317,7 +3317,7 @@ export type paths = { * notes/polls/recommendation * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *read:account* + * **Credential required**: *No* */ post: operations['notes___polls___recommendation']; }; @@ -5295,6 +5295,7 @@ export type components = { rejectReports: boolean; rejectQuotes: boolean; moderationNote?: string | null; + isBubbled: boolean; }; GalleryPost: { /** @@ -11210,6 +11211,7 @@ export type operations = { }]>; }; isModerator: boolean; + isAdministrator: boolean; isSystem: boolean; isSilenced: boolean; isSuspended: boolean; @@ -12919,6 +12921,9 @@ export type operations = { content: { 'application/json': { uri: string; + expandCollectionItems?: boolean; + expandCollectionLimit?: number | null; + allowAnonymous?: boolean; }; }; }; @@ -27495,7 +27500,7 @@ export type operations = { * notes/polls/recommendation * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *read:account* + * **Credential required**: *No* */ notes___polls___recommendation: { requestBody: { @@ -27507,6 +27512,10 @@ export type operations = { offset?: number; /** @default false */ excludeChannels?: boolean; + /** @default null */ + local?: boolean | null; + /** @default false */ + expired?: boolean; }; }; }; diff --git a/packages/misskey-js/tsconfig.json b/packages/misskey-js/tsconfig.json index 95128b8fab..e0603832c7 100644 --- a/packages/misskey-js/tsconfig.json +++ b/packages/misskey-js/tsconfig.json @@ -17,6 +17,7 @@ "esModuleInterop": true, "exactOptionalPropertyTypes": true, "skipLibCheck": true, + "incremental": true, "typeRoots": [ "./node_modules/@types" ], diff --git a/packages/misskey-reversi/tsconfig.json b/packages/misskey-reversi/tsconfig.json index f467951ef6..3cf8bb037f 100644 --- a/packages/misskey-reversi/tsconfig.json +++ b/packages/misskey-reversi/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitReturns": true, "esModuleInterop": true, "skipLibCheck": true, + "incremental": true, "typeRoots": [ "./node_modules/@types" ], diff --git a/packages/sw/tsconfig.json b/packages/sw/tsconfig.json index 112a932e58..3a78106e46 100644 --- a/packages/sw/tsconfig.json +++ b/packages/sw/tsconfig.json @@ -20,6 +20,7 @@ "resolveJsonModule": true, "isolatedModules": true, "skipLibCheck": true, + "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a20161993e..75771bb969 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -744,7 +744,7 @@ importers: version: 0.1.0-nightly.2024.10.15 '@sentry/vue': specifier: 9.14.0 - version: 9.14.0(vue@3.5.12(typescript@5.8.3)) + version: 9.14.0(vue@3.5.14(typescript@5.8.3)) '@syuilo/aiscript': specifier: 0.19.0 version: 0.19.0 @@ -756,10 +756,10 @@ importers: version: 15.1.1 '@vitejs/plugin-vue': specifier: 5.2.3 - version: 5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.12(typescript@5.8.3)) + version: 5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3)) '@vue/compiler-sfc': - specifier: 3.5.12 - version: 3.5.12 + specifier: 3.5.14 + version: 3.5.14 aiscript-vscode: specifier: github:aiscript-dev/aiscript-vscode#v0.1.15 version: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/c3cde89e79a41d93540cf8a48cd619c3f2dcb1b7 @@ -891,16 +891,16 @@ importers: version: 11.1.0 v-code-diff: specifier: 1.13.1 - version: 1.13.1(vue@3.5.12(typescript@5.8.3)) + version: 1.13.1(vue@3.5.14(typescript@5.8.3)) vite: specifier: 6.3.3 version: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vue: - specifier: 3.5.12 - version: 3.5.12(typescript@5.8.3) + specifier: 3.5.14 + version: 3.5.14(typescript@5.8.3) vuedraggable: specifier: next - version: 4.1.0(vue@3.5.12(typescript@5.8.3)) + version: 4.1.0(vue@3.5.14(typescript@5.8.3)) wanakana: specifier: 5.3.1 version: 5.3.1 @@ -962,13 +962,13 @@ importers: version: 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)) '@storybook/vue3': specifier: 8.6.12 - version: 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vue@3.5.12(typescript@5.8.3)) + version: 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vue@3.5.14(typescript@5.8.3)) '@storybook/vue3-vite': specifier: 8.6.12 - version: 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.12(typescript@5.8.3)) + version: 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3)) '@testing-library/vue': specifier: 8.1.0 - version: 8.1.0(@vue/compiler-sfc@3.5.12)(@vue/server-renderer@3.5.14(vue@3.5.12(typescript@5.8.3)))(vue@3.5.12(typescript@5.8.3)) + version: 8.1.0(@vue/compiler-sfc@3.5.14)(@vue/server-renderer@3.5.14(vue@3.5.14(typescript@5.8.3)))(vue@3.5.14(typescript@5.8.3)) '@types/canvas-confetti': specifier: 1.9.0 version: 1.9.0 @@ -1015,11 +1015,11 @@ importers: specifier: 3.1.2 version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) '@vue/compiler-core': - specifier: 3.5.12 - version: 3.5.12 + specifier: 3.5.14 + version: 3.5.14 '@vue/runtime-core': - specifier: 3.5.12 - version: 3.5.12 + specifier: 3.5.14 + version: 3.5.14 acorn: specifier: 8.14.1 version: 8.14.1 @@ -1121,10 +1121,10 @@ importers: version: 15.1.1 '@vitejs/plugin-vue': specifier: 5.2.3 - version: 5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.12(typescript@5.8.3)) + version: 5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3)) '@vue/compiler-sfc': - specifier: 3.5.12 - version: 3.5.12 + specifier: 3.5.14 + version: 3.5.14 astring: specifier: 1.9.0 version: 1.9.0 @@ -1174,15 +1174,15 @@ importers: specifier: 6.3.3 version: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vue: - specifier: 3.5.12 - version: 3.5.12(typescript@5.8.3) + specifier: 3.5.14 + version: 3.5.14(typescript@5.8.3) devDependencies: '@misskey-dev/summaly': specifier: 5.2.1 version: 5.2.1 '@testing-library/vue': specifier: 8.1.0 - version: 8.1.0(@vue/compiler-sfc@3.5.12)(@vue/server-renderer@3.5.14(vue@3.5.12(typescript@5.8.3)))(vue@3.5.12(typescript@5.8.3)) + version: 8.1.0(@vue/compiler-sfc@3.5.14)(@vue/server-renderer@3.5.14(vue@3.5.14(typescript@5.8.3)))(vue@3.5.14(typescript@5.8.3)) '@types/estree': specifier: 1.0.7 version: 1.0.7 @@ -1211,8 +1211,8 @@ importers: specifier: 3.1.2 version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) '@vue/runtime-core': - specifier: 3.5.12 - version: 3.5.12 + specifier: 3.5.14 + version: 3.5.14 acorn: specifier: 8.14.1 version: 8.14.1 @@ -4552,36 +4552,24 @@ packages: '@volar/typescript@2.4.12': resolution: {integrity: sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==} - '@vue/compiler-core@3.5.12': - resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==} - '@vue/compiler-core@3.5.13': resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} '@vue/compiler-core@3.5.14': resolution: {integrity: sha512-k7qMHMbKvoCXIxPhquKQVw3Twid3Kg4s7+oYURxLGRd56LiuHJVrvFKI4fm2AM3c8apqODPfVJGoh8nePbXMRA==} - '@vue/compiler-dom@3.5.12': - resolution: {integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==} - '@vue/compiler-dom@3.5.13': resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==} '@vue/compiler-dom@3.5.14': resolution: {integrity: sha512-1aOCSqxGOea5I80U2hQJvXYpPm/aXo95xL/m/mMhgyPUsKe9jhjwWpziNAw7tYRnbz1I61rd9Mld4W9KmmRoug==} - '@vue/compiler-sfc@3.5.12': - resolution: {integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==} - '@vue/compiler-sfc@3.5.13': resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==} '@vue/compiler-sfc@3.5.14': resolution: {integrity: sha512-9T6m/9mMr81Lj58JpzsiSIjBgv2LiVoWjIVa7kuXHICUi8LiDSIotMpPRXYJsXKqyARrzjT24NAwttrMnMaCXA==} - '@vue/compiler-ssr@3.5.12': - resolution: {integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==} - '@vue/compiler-ssr@3.5.13': resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==} @@ -4607,38 +4595,24 @@ packages: typescript: optional: true - '@vue/reactivity@3.5.12': - resolution: {integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==} - '@vue/reactivity@3.5.13': resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==} '@vue/reactivity@3.5.14': resolution: {integrity: sha512-7cK1Hp343Fu/SUCCO52vCabjvsYu7ZkOqyYu7bXV9P2yyfjUMUXHZafEbq244sP7gf+EZEz+77QixBTuEqkQQw==} - '@vue/runtime-core@3.5.12': - resolution: {integrity: sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==} - '@vue/runtime-core@3.5.13': resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==} '@vue/runtime-core@3.5.14': resolution: {integrity: sha512-w9JWEANwHXNgieAhxPpEpJa+0V5G0hz3NmjAZwlOebtfKyp2hKxKF0+qSh0Xs6/PhfGihuSdqMprMVcQU/E6ag==} - '@vue/runtime-dom@3.5.12': - resolution: {integrity: sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==} - '@vue/runtime-dom@3.5.13': resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==} '@vue/runtime-dom@3.5.14': resolution: {integrity: sha512-lCfR++IakeI35TVR80QgOelsUIdcKjd65rWAMfdSlCYnaEY5t3hYwru7vvcWaqmrK+LpI7ZDDYiGU5V3xjMacw==} - '@vue/server-renderer@3.5.12': - resolution: {integrity: sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==} - peerDependencies: - vue: 3.5.12 - '@vue/server-renderer@3.5.13': resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==} peerDependencies: @@ -4649,9 +4623,6 @@ packages: peerDependencies: vue: 3.5.14 - '@vue/shared@3.5.12': - resolution: {integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==} - '@vue/shared@3.5.13': resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} @@ -10658,14 +10629,6 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@3.5.12: - resolution: {integrity: sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - vue@3.5.13: resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} peerDependencies: @@ -13424,12 +13387,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@sentry/vue@9.14.0(vue@3.5.12(typescript@5.8.3))': - dependencies: - '@sentry/browser': 9.14.0 - '@sentry/core': 9.14.0 - vue: 3.5.12(typescript@5.8.3) - '@sentry/vue@9.14.0(vue@3.5.14(typescript@5.8.3))': dependencies: '@sentry/browser': 9.14.0 @@ -14157,32 +14114,32 @@ snapshots: dependencies: storybook: 8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5) - '@storybook/vue3-vite@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.12(typescript@5.8.3))': + '@storybook/vue3-vite@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3))': dependencies: '@storybook/builder-vite': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) - '@storybook/vue3': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vue@3.5.12(typescript@5.8.3)) + '@storybook/vue3': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vue@3.5.14(typescript@5.8.3)) find-package-json: 1.2.0 magic-string: 0.30.17 storybook: 8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5) typescript: 5.8.3 vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vue-component-meta: 2.0.16(typescript@5.8.3) - vue-docgen-api: 4.75.1(vue@3.5.12(typescript@5.8.3)) + vue-docgen-api: 4.75.1(vue@3.5.14(typescript@5.8.3)) transitivePeerDependencies: - vue - '@storybook/vue3@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vue@3.5.12(typescript@5.8.3))': + '@storybook/vue3@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))(vue@3.5.14(typescript@5.8.3))': dependencies: '@storybook/components': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)) '@storybook/global': 5.0.0 '@storybook/manager-api': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)) '@storybook/preview-api': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)) '@storybook/theming': 8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)) - '@vue/compiler-core': 3.5.12 + '@vue/compiler-core': 3.5.14 storybook: 8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5) ts-dedent: 2.2.0 type-fest: 2.19.0 - vue: 3.5.12(typescript@5.8.3) + vue: 3.5.14(typescript@5.8.3) vue-component-type-helpers: 2.2.10 '@stylistic/eslint-plugin@4.2.0(eslint@9.25.1)(typescript@5.8.3)': @@ -14330,14 +14287,14 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 - '@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.12)(@vue/server-renderer@3.5.14(vue@3.5.12(typescript@5.8.3)))(vue@3.5.12(typescript@5.8.3))': + '@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.14)(@vue/server-renderer@3.5.14(vue@3.5.14(typescript@5.8.3)))(vue@3.5.14(typescript@5.8.3))': dependencies: '@babel/runtime': 7.23.4 '@testing-library/dom': 9.3.4 - '@vue/test-utils': 2.4.1(@vue/server-renderer@3.5.14(vue@3.5.12(typescript@5.8.3)))(vue@3.5.12(typescript@5.8.3)) - vue: 3.5.12(typescript@5.8.3) + '@vue/test-utils': 2.4.1(@vue/server-renderer@3.5.14(vue@3.5.14(typescript@5.8.3)))(vue@3.5.14(typescript@5.8.3)) + vue: 3.5.14(typescript@5.8.3) optionalDependencies: - '@vue/compiler-sfc': 3.5.12 + '@vue/compiler-sfc': 3.5.14 transitivePeerDependencies: - '@vue/server-renderer' @@ -14787,10 +14744,10 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue@5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.12(typescript@5.8.3))': + '@vitejs/plugin-vue@5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3))': dependencies: vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) - vue: 3.5.12(typescript@5.8.3) + vue: 3.5.14(typescript@5.8.3) '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))': dependencies: @@ -14908,14 +14865,6 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.0.8 - '@vue/compiler-core@3.5.12': - dependencies: - '@babel/parser': 7.27.2 - '@vue/shared': 3.5.12 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - '@vue/compiler-core@3.5.13': dependencies: '@babel/parser': 7.27.2 @@ -14932,11 +14881,6 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.12': - dependencies: - '@vue/compiler-core': 3.5.12 - '@vue/shared': 3.5.12 - '@vue/compiler-dom@3.5.13': dependencies: '@vue/compiler-core': 3.5.13 @@ -14947,21 +14891,9 @@ snapshots: '@vue/compiler-core': 3.5.14 '@vue/shared': 3.5.14 - '@vue/compiler-sfc@3.5.12': - dependencies: - '@babel/parser': 7.27.2 - '@vue/compiler-core': 3.5.12 - '@vue/compiler-dom': 3.5.12 - '@vue/compiler-ssr': 3.5.12 - '@vue/shared': 3.5.12 - estree-walker: 2.0.2 - magic-string: 0.30.17 - postcss: 8.5.3 - source-map-js: 1.2.1 - '@vue/compiler-sfc@3.5.13': dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.27.2 '@vue/compiler-core': 3.5.13 '@vue/compiler-dom': 3.5.13 '@vue/compiler-ssr': 3.5.13 @@ -14983,11 +14915,6 @@ snapshots: postcss: 8.5.3 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.12': - dependencies: - '@vue/compiler-dom': 3.5.12 - '@vue/shared': 3.5.12 - '@vue/compiler-ssr@3.5.13': dependencies: '@vue/compiler-dom': 3.5.13 @@ -15028,10 +14955,6 @@ snapshots: optionalDependencies: typescript: 5.8.3 - '@vue/reactivity@3.5.12': - dependencies: - '@vue/shared': 3.5.12 - '@vue/reactivity@3.5.13': dependencies: '@vue/shared': 3.5.13 @@ -15040,11 +14963,6 @@ snapshots: dependencies: '@vue/shared': 3.5.14 - '@vue/runtime-core@3.5.12': - dependencies: - '@vue/reactivity': 3.5.12 - '@vue/shared': 3.5.12 - '@vue/runtime-core@3.5.13': dependencies: '@vue/reactivity': 3.5.13 @@ -15055,13 +14973,6 @@ snapshots: '@vue/reactivity': 3.5.14 '@vue/shared': 3.5.14 - '@vue/runtime-dom@3.5.12': - dependencies: - '@vue/reactivity': 3.5.12 - '@vue/runtime-core': 3.5.12 - '@vue/shared': 3.5.12 - csstype: 3.1.3 - '@vue/runtime-dom@3.5.13': dependencies: '@vue/reactivity': 3.5.13 @@ -15076,44 +14987,29 @@ snapshots: '@vue/shared': 3.5.14 csstype: 3.1.3 - '@vue/server-renderer@3.5.12(vue@3.5.12(typescript@5.8.3))': - dependencies: - '@vue/compiler-ssr': 3.5.12 - '@vue/shared': 3.5.12 - vue: 3.5.12(typescript@5.8.3) - '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.3))': dependencies: '@vue/compiler-ssr': 3.5.13 '@vue/shared': 3.5.13 vue: 3.5.13(typescript@5.8.3) - '@vue/server-renderer@3.5.14(vue@3.5.12(typescript@5.8.3))': - dependencies: - '@vue/compiler-ssr': 3.5.14 - '@vue/shared': 3.5.14 - vue: 3.5.12(typescript@5.8.3) - optional: true - '@vue/server-renderer@3.5.14(vue@3.5.14(typescript@5.8.3))': dependencies: '@vue/compiler-ssr': 3.5.14 '@vue/shared': 3.5.14 vue: 3.5.14(typescript@5.8.3) - '@vue/shared@3.5.12': {} - '@vue/shared@3.5.13': {} '@vue/shared@3.5.14': {} - '@vue/test-utils@2.4.1(@vue/server-renderer@3.5.14(vue@3.5.12(typescript@5.8.3)))(vue@3.5.12(typescript@5.8.3))': + '@vue/test-utils@2.4.1(@vue/server-renderer@3.5.14(vue@3.5.14(typescript@5.8.3)))(vue@3.5.14(typescript@5.8.3))': dependencies: js-beautify: 1.14.9 - vue: 3.5.12(typescript@5.8.3) + vue: 3.5.14(typescript@5.8.3) vue-component-type-helpers: 1.8.4 optionalDependencies: - '@vue/server-renderer': 3.5.14(vue@3.5.12(typescript@5.8.3)) + '@vue/server-renderer': 3.5.14(vue@3.5.14(typescript@5.8.3)) '@xhmikosr/archive-type@7.0.0': dependencies: @@ -21955,13 +21851,13 @@ snapshots: uuid@9.0.1: {} - v-code-diff@1.13.1(vue@3.5.12(typescript@5.8.3)): + v-code-diff@1.13.1(vue@3.5.14(typescript@5.8.3)): dependencies: diff: 5.2.0 diff-match-patch: 1.0.5 highlight.js: 11.10.0 - vue: 3.5.12(typescript@5.8.3) - vue-demi: 0.14.7(vue@3.5.12(typescript@5.8.3)) + vue: 3.5.14(typescript@5.8.3) + vue-demi: 0.14.7(vue@3.5.14(typescript@5.8.3)) v8-to-istanbul@9.2.0: dependencies: @@ -22119,24 +22015,24 @@ snapshots: vue-component-type-helpers@2.2.10: {} - vue-demi@0.14.7(vue@3.5.12(typescript@5.8.3)): + vue-demi@0.14.7(vue@3.5.14(typescript@5.8.3)): dependencies: - vue: 3.5.12(typescript@5.8.3) + vue: 3.5.14(typescript@5.8.3) - vue-docgen-api@4.75.1(vue@3.5.12(typescript@5.8.3)): + vue-docgen-api@4.75.1(vue@3.5.14(typescript@5.8.3)): dependencies: '@babel/parser': 7.25.7 '@babel/types': 7.25.7 '@vue/compiler-dom': 3.5.14 - '@vue/compiler-sfc': 3.5.12 + '@vue/compiler-sfc': 3.5.14 ast-types: 0.16.1 hash-sum: 2.0.0 lru-cache: 8.0.4 pug: 3.0.3 recast: 0.23.6 ts-map: 1.0.3 - vue: 3.5.12(typescript@5.8.3) - vue-inbrowser-compiler-independent-utils: 4.71.1(vue@3.5.12(typescript@5.8.3)) + vue: 3.5.14(typescript@5.8.3) + vue-inbrowser-compiler-independent-utils: 4.71.1(vue@3.5.14(typescript@5.8.3)) vue-eslint-parser@10.1.3(eslint@9.25.1): dependencies: @@ -22151,9 +22047,9 @@ snapshots: transitivePeerDependencies: - supports-color - vue-inbrowser-compiler-independent-utils@4.71.1(vue@3.5.12(typescript@5.8.3)): + vue-inbrowser-compiler-independent-utils@4.71.1(vue@3.5.14(typescript@5.8.3)): dependencies: - vue: 3.5.12(typescript@5.8.3) + vue: 3.5.14(typescript@5.8.3) vue-template-compiler@2.7.14: dependencies: @@ -22166,16 +22062,6 @@ snapshots: '@vue/language-core': 2.2.10(typescript@5.8.3) typescript: 5.8.3 - vue@3.5.12(typescript@5.8.3): - dependencies: - '@vue/compiler-dom': 3.5.12 - '@vue/compiler-sfc': 3.5.12 - '@vue/runtime-dom': 3.5.12 - '@vue/server-renderer': 3.5.12(vue@3.5.12(typescript@5.8.3)) - '@vue/shared': 3.5.12 - optionalDependencies: - typescript: 5.8.3 - vue@3.5.13(typescript@5.8.3): dependencies: '@vue/compiler-dom': 3.5.13 @@ -22196,10 +22082,10 @@ snapshots: optionalDependencies: typescript: 5.8.3 - vuedraggable@4.1.0(vue@3.5.12(typescript@5.8.3)): + vuedraggable@4.1.0(vue@3.5.14(typescript@5.8.3)): dependencies: sortablejs: 1.14.0 - vue: 3.5.12(typescript@5.8.3) + vue: 3.5.14(typescript@5.8.3) w3c-xmlserializer@5.0.0: dependencies: diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 0ad01b96be..4196e3ea09 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -371,6 +371,8 @@ _mfm: smallDescription: "Displays content small and thin." center: "Center" centerDescription: "Displays content centered." + unixtime: "Unix Time" + unixtimeDescription: "Displays a timestamp in the viewer's current timezone." inlineCode: "Code (Inline)" inlineCodeDescription: "Displays inline syntax highlighting for (program) code." blockCode: "Code (Block)" @@ -572,6 +574,12 @@ bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, an popularUsersGlobal: "Users popular on the global network" popularUsersLocal: "Users popular on {name}" +pollsOnLocal: "Polls trending on {name}" +pollsOnRemote: "Polls trending on the global network" +pollsExpired: "Polls that have ended recently" +trendingPollsDisabled: "Trending polls are disabled on this instance." +trendingPollsDisabledLogIn: "Please log in to view trending polls." + silenced: "Silenced" totalFollowers: "Total followers" totalFollowing: "Total following" @@ -593,3 +601,16 @@ translationTimeoutCaption: "Timeout in milliseconds for translation API requests attributionDomains: "Attribution Domains" attributionDomainsDescription: "A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:" writtenBy: "Written by {user}" + +followingPub: "Following (Pub)" +followersSub: "Followers (Sub)" +wellKnownResources: "Well-known resources" +lastPosted: "Last posted: {at}" +nsfw: "NSFW" +raw: "Raw" +cw: "CW" +mediaSilenced: "Media Silenced" +bubble: "Bubble" +verified: "Verified" +notVerified: "Not Verified" +hibernated: "Hibernated" diff --git a/sharkey-locales/th-TH.yml b/sharkey-locales/th-TH.yml index 9da88bc05c..c4670d48bb 100644 --- a/sharkey-locales/th-TH.yml +++ b/sharkey-locales/th-TH.yml @@ -26,7 +26,7 @@ _timelineDescription: _timelines: bubble: "ฟอง" -scheduleNoteFailed: "การกำหนดเวลาเผยแพร่โพสต์ล้มเหลว" +scheduleNoteFailed: "การกำหนดเวลาเผยแพร่โพสต์ล้มเหลว" scheduleNotePosted: "มีการโพสต์กำหนดการแล้ว" @@ -41,3 +41,14 @@ _externalNavigationWarning: trustThisDomain: "วางใจโดเมนนี้บนอุปกรณ์นี้ในอนาคต" mutuals: "ติดตามกันและกัน" + +_moderationLogTypes: + approve: "ที่ได้รับการอนุมัติ" + decline: "ปฏิเสธ" + setMandatoryCW: "ตั้งค่าคำเตือนเนื้อหาสำหรับผู้ใช้" + setRemoteInstanceNSFW: "ตั้งค่าเซิร์ฟเวอร์ระยะไกลเป็น NSFW" + unsetRemoteInstanceNSFW: "ปิดการใช้งานการตั้งค่าเซิร์ฟเวอร์ระยะไกลเป็น NSFW" + rejectRemoteInstanceReports: "รายงานถูกปฏิเสธจากเซิร์ฟเวอร์ระยะไกล" + acceptRemoteInstanceReports: "รายงานได้รับการยอมรับจากเซิร์ฟเวอร์ระยะไกล" + rejectQuotesUser: "โพสต์คำพูดของผู้ใช้ถูกบล็อค/ลบ" + allowQuotesUser: "อนุญาตให้อ้างอิงข้อความจากผู้ใช้" diff --git a/sharkey-locales/vi-VN.yml b/sharkey-locales/vi-VN.yml index 361083d69e..c382e317e6 100644 --- a/sharkey-locales/vi-VN.yml +++ b/sharkey-locales/vi-VN.yml @@ -36,3 +36,14 @@ _externalNavigationWarning: trustThisDomain: "Tin tưởng tên miền này trên thiết bị này trong tương lai" misskeyUpdated: "Sharkey vừa được cập nhật!" mutuals: "Theo dõi nhau" + +_moderationLogTypes: + approve: "Đã chấp thuận" + decline: "Từ chối" + setMandatoryCW: "Đặt cảnh báo nội dung cho người dùng" + setRemoteInstanceNSFW: "Đặt máy chủ từ xa thành NSFW" + unsetRemoteInstanceNSFW: "Hủy cài đặt máy chủ từ xa thành NSFW" + rejectRemoteInstanceReports: "Báo cáo bị từ chối từ máy chủ từ xa" + acceptRemoteInstanceReports: "Đã chấp nhận báo cáo từ máy chủ từ xa" + rejectQuotesUser: "Đã chặn/Xóa bài đăng trích dẫn của người dùng" + allowQuotesUser: "Cho phép trích dẫn bài đăng từ người dùng" |