mirror of
https://activitypub.software/TransFem-org/Sharkey.git
synced 2025-08-31 22:50:43 +00:00
Merge branch 'develop' into upstream/2025.5.0
This commit is contained in:
commit
46bb75d274
116 changed files with 2636 additions and 973 deletions
|
@ -297,6 +297,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
|
||||
|
||||
|
@ -321,9 +325,24 @@ attachLdSignatureForRelays: true
|
|||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
# Some example configurations:
|
||||
#allowedPrivateNetworks:
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']
|
||||
|
||||
|
|
|
@ -260,6 +260,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
|
||||
|
||||
|
@ -269,9 +273,27 @@ proxyRemoteFiles: true
|
|||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
# Some example configurations:
|
||||
allowedPrivateNetworks:
|
||||
# Allow connections to 127.0.0.1 on any port
|
||||
- '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
# Disable automatic redirect for ActivityPub object lookup. (default: false)
|
||||
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
|
||||
|
|
|
@ -351,6 +351,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
|
||||
|
@ -378,9 +382,24 @@ attachLdSignatureForRelays: true
|
|||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
# Some example configurations:
|
||||
#allowedPrivateNetworks:
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']
|
||||
|
||||
|
|
|
@ -354,6 +354,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
|
||||
|
@ -381,9 +385,24 @@ attachLdSignatureForRelays: true
|
|||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
# Some example configurations:
|
||||
#allowedPrivateNetworks:
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
86
locales/index.d.ts
vendored
86
locales/index.d.ts
vendored
|
@ -2451,7 +2451,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"disablePagesScript": string;
|
||||
/**
|
||||
* リモートユーザー情報の更新
|
||||
* Refresh remote data
|
||||
*/
|
||||
"updateRemoteUser": string;
|
||||
/**
|
||||
|
@ -7631,6 +7631,10 @@ export interface Locale extends ILocale {
|
|||
* Maximum number of scheduled notes
|
||||
*/
|
||||
"scheduleNoteMax": string;
|
||||
/**
|
||||
* Can appear in trending notes / users
|
||||
*/
|
||||
"canTrend": string;
|
||||
};
|
||||
"_condition": {
|
||||
/**
|
||||
|
@ -12961,6 +12965,14 @@ export interface Locale extends ILocale {
|
|||
* Fetch linked note
|
||||
*/
|
||||
"fetchLinkedNote": string;
|
||||
/**
|
||||
* Add "Translate" to note action menu
|
||||
*/
|
||||
"showTranslationButtonInNoteFooter": string;
|
||||
/**
|
||||
* Failed to translate note. Please try again later or contact an administrator for assistance.
|
||||
*/
|
||||
"translationFailed": string;
|
||||
"_processErrors": {
|
||||
/**
|
||||
* Unable to process quote. This post may be missing context.
|
||||
|
@ -13065,6 +13077,10 @@ export interface Locale extends ILocale {
|
|||
* Text does not match any patterns.
|
||||
*/
|
||||
"wordMuteTestNoMatch": string;
|
||||
/**
|
||||
* All word mutes are *case-sensitive* and match on any substring, including part of a longer word or name. You can use regular expressions for more precise control.
|
||||
*/
|
||||
"wordMuteWarning": string;
|
||||
/**
|
||||
* Bubble timeline
|
||||
*/
|
||||
|
@ -13077,6 +13093,74 @@ export interface Locale extends ILocale {
|
|||
* Note: the bubble timeline is hidden by default, and must be enabled via roles.
|
||||
*/
|
||||
"bubbleTimelineMustBeEnabled": string;
|
||||
/**
|
||||
* Users popular on the global network
|
||||
*/
|
||||
"popularUsersGlobal": string;
|
||||
/**
|
||||
* Users popular on {name}
|
||||
*/
|
||||
"popularUsersLocal": ParameterizedString<"name">;
|
||||
/**
|
||||
* Silenced
|
||||
*/
|
||||
"silenced": string;
|
||||
/**
|
||||
* Total followers
|
||||
*/
|
||||
"totalFollowers": string;
|
||||
/**
|
||||
* Total following
|
||||
*/
|
||||
"totalFollowing": string;
|
||||
/**
|
||||
* Local followers
|
||||
*/
|
||||
"localFollowers": string;
|
||||
/**
|
||||
* Local following
|
||||
*/
|
||||
"localFollowing": string;
|
||||
/**
|
||||
* Remote followers
|
||||
*/
|
||||
"remoteFollowers": string;
|
||||
/**
|
||||
* Remote following
|
||||
*/
|
||||
"remoteFollowing": string;
|
||||
/**
|
||||
* Activity Pub
|
||||
*/
|
||||
"activityPub": string;
|
||||
/**
|
||||
* IP
|
||||
*/
|
||||
"ip": string;
|
||||
/**
|
||||
* The date is when IP address was first used.
|
||||
*/
|
||||
"ipTip": string;
|
||||
/**
|
||||
* Period
|
||||
*/
|
||||
"rolePeriod": string;
|
||||
/**
|
||||
* Assigned
|
||||
*/
|
||||
"roleAssigned": string;
|
||||
/**
|
||||
* automatic
|
||||
*/
|
||||
"roleAutomatic": string;
|
||||
/**
|
||||
* Translation timeout
|
||||
*/
|
||||
"translationTimeoutLabel": string;
|
||||
/**
|
||||
* Timeout in milliseconds for translation API requests.
|
||||
*/
|
||||
"translationTimeoutCaption": string;
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sharkey",
|
||||
"version": "2025.5.1-rc",
|
||||
"version": "2025.5.2-dev",
|
||||
"codename": "shonk",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddMetaTranslationTimeout1747023091463 {
|
||||
name = 'AddMetaTranslationTimeout1747023091463'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "translationTimeout" integer NOT NULL DEFAULT '5000'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "meta"."translationTimeout" IS 'Timeout in milliseconds for translation API requests'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`COMMENT ON COLUMN "meta"."translationTimeout" IS 'Timeout in milliseconds for translation API requests'`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "translationTimeout"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class RenameFollowingVisibility1747934911491 {
|
||||
name = 'RenameFollowingVisibility1747934911491'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_followingvisibility_enum" RENAME TO "user_profile_followingVisibility_enum"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_followingVisibility_enum" RENAME TO "user_profile_followingvisibility_enum"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddEntityComments1747935197708 {
|
||||
name = 'AddEntityComments1747935197708'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."backgroundId" IS 'The ID of background DriveFile.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."isSilenced" IS 'Whether the User is silenced.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."noindex" IS 'Whether the User''s notes dont get indexed.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."speakAsCat" IS 'Whether the User speaks in nya.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_profile"."listenbrainz" IS 'The ListenBrainz username of the User.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The update time of the Note.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "meta"."trustedLinkUrlPatterns" IS 'An array of URL strings or regex that can be used to omit warnings about redirects to external sites. Separate them with spaces to specify AND, and enclose them with slashes to specify regular expressions. Each item is regarded as an OR.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."oldDate" IS 'The old date from before the edit'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."oldDate" IS NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "meta"."trustedLinkUrlPatterns" IS NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_profile"."listenbrainz" IS 'listenbrainz username to fetch currently playing.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."speakAsCat" IS 'Whether to speak as a cat if chosen.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."noindex" IS NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."isSilenced" IS NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."backgroundId" IS NULL`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixSystemWebhookUpdatedAtDefault1747937504140 {
|
||||
name = 'FixSystemWebhookUpdatedAtDefault1747937504140'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT now()`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixAbuseReportNotificationRecipientUpdatedAtDefault1747937670341 {
|
||||
name = 'FixAbuseReportNotificationRecipientUpdatedAtDefault1747937670341'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT now()`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixFlashVisibilityNullable1747937796573 {
|
||||
name = 'FixFlashVisibilityNullable1747937796573'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" SET NOT NULL`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixAbuseReportNotificationRecipientDefaults1747938136399 {
|
||||
name = 'FixAbuseReportNotificationRecipientDefaults1747938136399'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" DROP DEFAULT`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" SET DEFAULT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" SET DEFAULT NULL`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixMetaUrlPreviewUserAgentDefault1747938263980 {
|
||||
name = 'FixMetaUrlPreviewUserAgentDefault1747938263980'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" DROP DEFAULT`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" SET DEFAULT NULL`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class 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 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"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AlterMetaDefaultLikeNotNull1747944466178 {
|
||||
name = 'AlterMetaDefaultLikeNotNull1747944466178'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "defaultLike" SET NOT NULL`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "defaultLike" DROP NOT NULL`);
|
||||
}
|
||||
}
|
|
@ -8,9 +8,11 @@ import { fileURLToPath } from 'node:url';
|
|||
import { dirname, resolve } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { globSync } from 'glob';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import type { IPv4, IPv6 } from 'ipaddr.js';
|
||||
|
||||
type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
host?: string;
|
||||
|
@ -82,7 +84,7 @@ type Source = {
|
|||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
allowedPrivateNetworks?: PrivateNetworkSource[];
|
||||
disallowExternalApRedirect?: boolean;
|
||||
|
||||
maxFileSize?: number;
|
||||
|
@ -109,6 +111,7 @@ type Source = {
|
|||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
mediaDirectory?: string;
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
videoThumbnailGenerator?: string;
|
||||
|
@ -152,6 +155,60 @@ type Source = {
|
|||
}
|
||||
};
|
||||
|
||||
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
||||
|
||||
export type PrivateNetwork = {
|
||||
/**
|
||||
* CIDR IP/netmask definition of the IP range to match.
|
||||
*/
|
||||
cidr: CIDR;
|
||||
|
||||
/**
|
||||
* List of ports to match.
|
||||
* If undefined, then all ports match.
|
||||
* If empty, then NO ports match.
|
||||
*/
|
||||
ports?: number[];
|
||||
};
|
||||
|
||||
export type CIDR = [ip: IPv4 | IPv6, prefixLength: number];
|
||||
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[]): PrivateNetwork[];
|
||||
export function parsePrivateNetworks(patterns: undefined): undefined;
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined;
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined {
|
||||
if (!patterns) return undefined;
|
||||
return patterns
|
||||
.map(e => {
|
||||
if (typeof(e) === 'string') {
|
||||
const cidr = parseIpOrMask(e);
|
||||
if (cidr) {
|
||||
return { cidr } satisfies PrivateNetwork;
|
||||
}
|
||||
} else if (e.network) {
|
||||
const cidr = parseIpOrMask(e.network);
|
||||
if (cidr) {
|
||||
return { cidr, ports: e.ports } satisfies PrivateNetwork;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[config] Skipping invalid entry in allowedPrivateNetworks: ', e);
|
||||
return null;
|
||||
})
|
||||
.filter(p => p != null);
|
||||
}
|
||||
|
||||
function parseIpOrMask(ipOrMask: string): CIDR | null {
|
||||
if (ipaddr.isValidCIDR(ipOrMask)) {
|
||||
return ipaddr.parseCIDR(ipOrMask);
|
||||
}
|
||||
if (ipaddr.isValid(ipOrMask)) {
|
||||
const ip = ipaddr.parse(ipOrMask);
|
||||
return [ip, 32];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
url: string;
|
||||
port: number;
|
||||
|
@ -190,7 +247,7 @@ export type Config = {
|
|||
proxy: string | undefined;
|
||||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
allowedPrivateNetworks: PrivateNetwork[] | undefined;
|
||||
disallowExternalApRedirect: boolean;
|
||||
maxFileSize: number;
|
||||
maxNoteLength: number;
|
||||
|
@ -241,6 +298,7 @@ export type Config = {
|
|||
frontendManifestExists: boolean;
|
||||
frontendEmbedEntry: string;
|
||||
frontendEmbedManifestExists: boolean;
|
||||
mediaDirectory: string;
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
videoThumbnailGenerator: string | null;
|
||||
|
@ -290,7 +348,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
|
||||
|
@ -382,7 +440,7 @@ export function loadConfig(): Config {
|
|||
proxy: config.proxy,
|
||||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks),
|
||||
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
maxNoteLength: config.maxNoteLength ?? 3000,
|
||||
|
@ -407,6 +465,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 ?
|
||||
|
@ -575,14 +634,14 @@ function applyEnvOverrides(config: Source) {
|
|||
['host', 'port', 'username', 'pass', 'db', 'prefix'],
|
||||
]);
|
||||
_apply_top(['fulltextSearch', 'provider']);
|
||||
_apply_top(['meilisearch', ['host', 'port', 'apikey', 'ssl', 'index', 'scope']]);
|
||||
_apply_top(['meilisearch', ['host', 'port', 'apiKey', 'ssl', 'index', 'scope']]);
|
||||
_apply_top([['sentryForFrontend', 'sentryForBackend'], 'options', ['dsn', 'profileSampleRate', 'serverName', 'includeLocalVariables', 'proxy', 'keepAlive', 'caCerts']]);
|
||||
_apply_top(['sentryForBackend', 'enableNodeProfiling']);
|
||||
_apply_top(['sentryForFrontend', 'vueIntegration', ['attachProps', 'attachErrorHandler']]);
|
||||
_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']]);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
@ -22,6 +22,17 @@ export interface FollowStats {
|
|||
remoteFollowers: number;
|
||||
}
|
||||
|
||||
export interface CachedTranslation {
|
||||
sourceLang: string | undefined;
|
||||
text: string | undefined;
|
||||
}
|
||||
|
||||
interface CachedTranslationEntity {
|
||||
l?: string;
|
||||
t?: string;
|
||||
u?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: MemoryKVCache<MiUser>;
|
||||
|
@ -35,6 +46,7 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
||||
private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
||||
private readonly translationsCache: RedisKVCache<CachedTranslationEntity>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
@ -124,6 +136,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
|
||||
this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
|
||||
lifetime: 1000 * 60 * 60 * 24 * 7, // 1 week,
|
||||
memoryCacheLifetime: 1000 * 60, // 1 minute
|
||||
});
|
||||
|
||||
// NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
|
@ -253,6 +270,34 @@ export class CacheService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getCachedTranslation(note: MiNote, targetLang: string): Promise<CachedTranslation | null> {
|
||||
const cacheKey = `${note.id}@${targetLang}`;
|
||||
|
||||
// Use cached translation, if present and up-to-date
|
||||
const cached = await this.translationsCache.get(cacheKey);
|
||||
if (cached && cached.u === note.updatedAt?.valueOf()) {
|
||||
return {
|
||||
sourceLang: cached.l,
|
||||
text: cached.t,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache entry :(
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async setCachedTranslation(note: MiNote, targetLang: string, translation: CachedTranslation): Promise<void> {
|
||||
const cacheKey = `${note.id}@${targetLang}`;
|
||||
|
||||
await this.translationsCache.set(cacheKey, {
|
||||
l: translation.sourceLang,
|
||||
t: translation.text,
|
||||
u: note.updatedAt?.valueOf(),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
|
|
|
@ -63,8 +63,6 @@ export class DeleteAccountService {
|
|||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
|
@ -73,22 +71,17 @@ export class DeleteAccountService {
|
|||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
const inboxes = followings.map(x => [x.followerSharedInbox ?? x.followeeSharedInbox as string, true] as const);
|
||||
const queue = new Map<string, true>(inboxes);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
await this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
} else {
|
||||
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
await this.queueService.createDeleteAccountJob(user, {
|
||||
soft: true,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import * as Redis from 'ioredis';
|
|||
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
|
@ -21,6 +22,8 @@ export class FeaturedService {
|
|||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -31,7 +34,14 @@ export class FeaturedService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score: number, userId: string | null): Promise<void> {
|
||||
if (userId) {
|
||||
const policies = await this.roleService.getUserPolicies(userId);
|
||||
if (!policies.canTrend) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const redisTransaction = this.redisClient.multi();
|
||||
redisTransaction.zincrby(
|
||||
|
@ -89,28 +99,28 @@ export class FeaturedService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updateGlobalNotesRanking(note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score);
|
||||
public updateGalleryPostsRanking(galleryPost: Pick<MiGalleryPost, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick<MiNote, 'id'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score, null);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -12,7 +12,7 @@ import fetch from 'node-fetch';
|
|||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Config, PrivateNetwork } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
|
@ -20,12 +20,36 @@ import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
|||
import { ApUtilityService } from './activitypub/ApUtilityService.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export type HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: boolean;
|
||||
validators?: ((res: Response) => void)[];
|
||||
};
|
||||
|
||||
export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(cidr)) {
|
||||
if (ports == null || (port != null && ports.includes(port))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
|
||||
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
|
||||
const address = socket.remoteAddress;
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'node:http' {
|
||||
interface Agent {
|
||||
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||
|
@ -44,31 +68,12 @@ class HttpRequestServiceAgent extends http.Agent {
|
|||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
validateSocketConnect(this.config.allowedPrivateNetworks, socket);
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
class HttpsRequestServiceAgent extends https.Agent {
|
||||
|
@ -83,31 +88,12 @@ class HttpsRequestServiceAgent extends https.Agent {
|
|||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
validateSocketConnect(this.config.allowedPrivateNetworks, socket);
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
@ -250,6 +236,8 @@ export class HttpRequestService {
|
|||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -592,6 +592,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
if (!this.isRenote(note) || this.isQuote(note)) {
|
||||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
} else {
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
this.pushToTl(note, user);
|
||||
|
@ -631,7 +633,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) {
|
||||
this.incRenoteCount(data.renote);
|
||||
this.incRenoteCount(data.renote, user);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
|
@ -814,8 +816,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private incRenoteCount(renote: MiNote) {
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
private async incRenoteCount(renote: MiNote, user: MiUser) {
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
})
|
||||
|
@ -823,15 +825,18 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
.execute();
|
||||
|
||||
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote.id, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
|
||||
if (user.isExplorable && Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
const policies = await this.roleService.getUserPolicies(user);
|
||||
if (policies.canTrend) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,9 +124,11 @@ export class NoteDeleteService {
|
|||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
if (!isRenote(note) || isQuote(note)) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
} else {
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
|
@ -165,8 +167,11 @@ export class NoteDeleteService {
|
|||
});
|
||||
}
|
||||
|
||||
if (note.uri) {
|
||||
this.apLogService.deleteObjectLogs(note.uri)
|
||||
const deletedUris = [note, ...cascadingNotes]
|
||||
.map(n => n.uri)
|
||||
.filter((u): u is string => u != null);
|
||||
if (deletedUris.length > 0) {
|
||||
this.apLogService.deleteObjectLogs(deletedUris)
|
||||
.catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
// ハッシュタグ更新
|
||||
this.pushToTl(note, user);
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
|
|||
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';
|
||||
|
||||
const FALLBACK = '\u2764';
|
||||
|
||||
|
@ -102,6 +103,7 @@ export class ReactionService {
|
|||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private notificationService: NotificationService,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -212,20 +214,28 @@ export class ReactionService {
|
|||
.execute();
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (
|
||||
Math.random() < 0.3 &&
|
||||
note.userId !== user.id &&
|
||||
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
|
||||
) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note.id, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
|
||||
const author = await this.cacheService.findUserById(note.userId);
|
||||
if (author.isExplorable) {
|
||||
const policies = await this.roleService.getUserPolicies(author);
|
||||
if (policies.canTrend) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -298,9 +308,9 @@ export class ReactionService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) {
|
||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) {
|
||||
// if already unreacted
|
||||
const exist = await this.noteReactionsRepository.findOneBy({
|
||||
exist ??= await this.noteReactionsRepository.findOneBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
@ -330,6 +340,8 @@ export class ReactionService {
|
|||
.execute();
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
|
|
|
@ -69,6 +69,7 @@ export type RolePolicies = {
|
|||
canImportMuting: boolean;
|
||||
canImportUserLists: boolean;
|
||||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
canTrend: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICIES: RolePolicies = {
|
||||
|
@ -108,6 +109,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canImportMuting: true,
|
||||
canImportUserLists: true,
|
||||
chatAvailability: 'available',
|
||||
canTrend: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
@ -149,6 +151,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
) {
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
|
||||
// TODO additional cache for final calculation?
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
@ -358,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserAssigns(userId: MiUser['id']) {
|
||||
public async getUserAssigns(userOrId: MiUser | MiUser['id']) {
|
||||
const now = Date.now();
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
|
@ -367,12 +371,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserRoles(userId: MiUser['id']) {
|
||||
public async getUserRoles(userOrId: MiUser | MiUser['id']) {
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
const followStats = await this.cacheService.getFollowStats(userId);
|
||||
const assigns = await this.getUserAssigns(userId);
|
||||
const assigns = await this.getUserAssigns(userOrId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
}
|
||||
|
@ -381,8 +386,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
* 指定ユーザーのバッジロール一覧取得
|
||||
*/
|
||||
@bindThis
|
||||
public async getUserBadgeRoles(userId: MiUser['id']) {
|
||||
public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) {
|
||||
const now = Date.now();
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
|
@ -392,7 +398,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||
if (badgeCondRoles.length > 0) {
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||
} else {
|
||||
|
@ -401,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserPolicies(userId: MiUser['id'] | null): Promise<RolePolicies> {
|
||||
public async getUserPolicies(userOrId: MiUser | MiUser['id'] | null): Promise<RolePolicies> {
|
||||
const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies };
|
||||
|
||||
if (userId == null) return basePolicies;
|
||||
if (userOrId == null) return basePolicies;
|
||||
|
||||
const roles = await this.getUserRoles(userId);
|
||||
const roles = await this.getUserRoles(userOrId);
|
||||
|
||||
function calc<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) {
|
||||
if (roles.length === 0) return basePolicies[name];
|
||||
|
@ -465,6 +471,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
|
||||
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
|
||||
chatAvailability: calc('chatAvailability', aggregateChatAvailability),
|
||||
canTrend: calc('canTrend', vs => vs.some(v => v === true)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
@ -186,6 +188,8 @@ export class ApRequestService {
|
|||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
|
|
|
@ -77,16 +77,48 @@ export class ApUtilityService {
|
|||
return acceptableUrls[0]?.url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL contains a fragment
|
||||
* @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`);
|
||||
}
|
||||
}
|
||||
|
||||
// Hash component breaks federation
|
||||
if (url.hash) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: contains a fragment (#)`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -19,16 +19,16 @@ export class RedisKVCache<T> {
|
|||
opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
|
||||
fetcher?: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter?: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter?: RedisKVCache<T>['fromRedisConverter'];
|
||||
},
|
||||
) {
|
||||
this.lifetime = opts.lifetime;
|
||||
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
this.toRedisConverter = opts.toRedisConverter;
|
||||
this.fromRedisConverter = opts.fromRedisConverter;
|
||||
this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); });
|
||||
this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value));
|
||||
this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -22,7 +22,7 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 有効かどうか.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_isActive')
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
|
@ -47,7 +47,7 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 通知方法.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_method')
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
})
|
||||
|
@ -56,7 +56,7 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 通知先のユーザID.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_userId')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -75,14 +75,16 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 通知先のユーザプロフィール.
|
||||
*/
|
||||
@ManyToOne(type => MiUserProfile, {})
|
||||
@ManyToOne(type => MiUserProfile, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' })
|
||||
public userProfile: MiUserProfile | null;
|
||||
|
||||
/**
|
||||
* 通知先のシステムWebhookId.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_systemWebhookId')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -95,6 +97,8 @@ export class MiAbuseReportNotificationRecipient {
|
|||
@ManyToOne(type => MiSystemWebhook, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
@JoinColumn({
|
||||
foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId',
|
||||
})
|
||||
public systemWebhook: MiSystemWebhook | null;
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ export class MiEmoji {
|
|||
})
|
||||
public host: string | null;
|
||||
|
||||
@Index('IDX_EMOJI_CATEGORY')
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
|
@ -77,6 +78,8 @@ export class MiEmoji {
|
|||
public isSensitive: boolean;
|
||||
|
||||
// TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする
|
||||
// Synchronize: false is needed because TypeORM doesn't understand GIN indexes
|
||||
@Index('IDX_EMOJI_ROLE_IDS', { synchronize: false })
|
||||
@Column('varchar', {
|
||||
array: true, length: 128, default: '{}',
|
||||
})
|
||||
|
|
|
@ -45,6 +45,7 @@ export class SkLatestNote {
|
|||
})
|
||||
@JoinColumn({
|
||||
name: 'user_id',
|
||||
foreignKeyConstraintName: 'FK_20e346fffe4a2174585005d6d80',
|
||||
})
|
||||
public user: MiUser | null;
|
||||
|
||||
|
@ -60,6 +61,7 @@ export class SkLatestNote {
|
|||
})
|
||||
@JoinColumn({
|
||||
name: 'note_id',
|
||||
foreignKeyConstraintName: 'FK_47a38b1c13de6ce4e5090fb1acd',
|
||||
})
|
||||
public note: MiNote | null;
|
||||
|
||||
|
|
|
@ -60,7 +60,7 @@ export class MiMeta {
|
|||
public maintainerEmail: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
default: true,
|
||||
})
|
||||
public disableRegistration: boolean;
|
||||
|
||||
|
@ -382,6 +382,12 @@ export class MiMeta {
|
|||
})
|
||||
public swPrivateKey: string | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 5000,
|
||||
comment: 'Timeout in milliseconds for translation API requests',
|
||||
})
|
||||
public translationTimeout: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
|
@ -425,7 +431,7 @@ export class MiMeta {
|
|||
@Column('varchar', {
|
||||
length: 1024,
|
||||
default: 'https://activitypub.software/TransFem-org/Sharkey/',
|
||||
nullable: false,
|
||||
nullable: true,
|
||||
})
|
||||
public repositoryUrl: string | null;
|
||||
|
||||
|
@ -612,8 +618,8 @@ export class MiMeta {
|
|||
})
|
||||
public enableAchievements: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
@Column('text', {
|
||||
nullable: true,
|
||||
})
|
||||
public robotsTxt: string | null;
|
||||
|
||||
|
@ -643,7 +649,7 @@ export class MiMeta {
|
|||
public bannedEmailDomains: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
|
||||
length: 1024, array: true, default: '{admin,administrator,root,system,maintainer,host,mod,moderator,owner,superuser,staff,auth,i,me,everyone,all,mention,mentions,example,user,users,account,accounts,official,help,helps,support,supports,info,information,informations,announce,announces,announcement,announcements,notice,notification,notifications,dev,developer,developers,tech,misskey}',
|
||||
})
|
||||
public preservedUsernames: string[];
|
||||
|
||||
|
@ -658,22 +664,22 @@ export class MiMeta {
|
|||
public enableFanoutTimelineDbFallback: boolean;
|
||||
|
||||
@Column('integer', {
|
||||
default: 300,
|
||||
default: 800,
|
||||
})
|
||||
public perLocalUserUserTimelineCacheMax: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 100,
|
||||
default: 800,
|
||||
})
|
||||
public perRemoteUserUserTimelineCacheMax: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 300,
|
||||
default: 800,
|
||||
})
|
||||
public perUserHomeTimelineCacheMax: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 300,
|
||||
default: 800,
|
||||
})
|
||||
public perUserListTimelineCacheMax: number;
|
||||
|
||||
|
@ -689,9 +695,9 @@ export class MiMeta {
|
|||
|
||||
@Column('varchar', {
|
||||
length: 500,
|
||||
nullable: true,
|
||||
default: '❤️',
|
||||
})
|
||||
public defaultLike: string | null;
|
||||
public defaultLike: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256, array: true, default: '{}',
|
||||
|
@ -714,7 +720,7 @@ export class MiMeta {
|
|||
public urlPreviewMaximumContentLength: number;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
default: false,
|
||||
})
|
||||
public urlPreviewRequireContentLength: boolean;
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import type { MiDriveFile } from './DriveFile.js';
|
|||
// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail
|
||||
// because it will always run CREATE INDEX in transaction based on decorators.
|
||||
// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production,
|
||||
@Index(['userId', 'id'])
|
||||
@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id'])
|
||||
@Entity('note')
|
||||
export class MiNote {
|
||||
@PrimaryColumn(id())
|
||||
|
@ -273,3 +273,7 @@ export type IMentionedRemoteUsers = {
|
|||
username: string;
|
||||
host: string;
|
||||
}[];
|
||||
|
||||
export function hasText(note: MiNote): note is MiNote & { text: string } {
|
||||
return note.text != null;
|
||||
}
|
||||
|
|
|
@ -129,7 +129,9 @@ export class MiUser {
|
|||
@OneToOne(() => MiDriveFile, {
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@JoinColumn()
|
||||
@JoinColumn({
|
||||
foreignKeyConstraintName: 'FK_q5lm0tbgejtfskzg0rc4wd7t1n',
|
||||
})
|
||||
public background: MiDriveFile | null;
|
||||
|
||||
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
|
||||
|
@ -345,7 +347,7 @@ export class MiUser {
|
|||
*/
|
||||
@Column('boolean', {
|
||||
name: 'enable_rss',
|
||||
default: true,
|
||||
default: false,
|
||||
})
|
||||
public enableRss: boolean;
|
||||
|
||||
|
|
|
@ -24,7 +24,9 @@ export class MiUserListMembership {
|
|||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
@JoinColumn({
|
||||
foreignKeyConstraintName: 'FK_d844bfc6f3f523a05189076efaa',
|
||||
})
|
||||
public user: MiUser | null;
|
||||
|
||||
@Index()
|
||||
|
@ -37,7 +39,9 @@ export class MiUserListMembership {
|
|||
@ManyToOne(type => MiUserList, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
@JoinColumn({
|
||||
foreignKeyConstraintName: 'FK_605472305f26818cc93d1baaa74',
|
||||
})
|
||||
public userList: MiUserList | null;
|
||||
|
||||
// タイムラインにその人のリプライまで含めるかどうか
|
||||
|
|
|
@ -34,6 +34,7 @@ export class MiUserPending {
|
|||
|
||||
@Column('varchar', {
|
||||
length: 1000,
|
||||
nullable: true,
|
||||
})
|
||||
public reason: string;
|
||||
}
|
||||
|
|
|
@ -110,12 +110,14 @@ export class MiUserProfile {
|
|||
|
||||
@Column('enum', {
|
||||
enum: followingVisibilities,
|
||||
enumName: 'user_profile_followingVisibility_enum',
|
||||
default: 'public',
|
||||
})
|
||||
public followingVisibility: typeof followingVisibilities[number];
|
||||
|
||||
@Column('enum', {
|
||||
enum: followersVisibilities,
|
||||
enumName: 'user_profile_followersVisibility_enum',
|
||||
default: 'public',
|
||||
})
|
||||
public followersVisibility: typeof followersVisibilities[number];
|
||||
|
|
|
@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = {
|
|||
optional: false, nullable: false,
|
||||
enum: ['available', 'readonly', 'unavailable'],
|
||||
},
|
||||
canTrend: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MoreThan } from 'typeorm';
|
||||
import { In, MoreThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository, RegistryItemsRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
|
@ -17,10 +17,10 @@ import { bindThis } from '@/decorators.js';
|
|||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { ApLogService } from '@/core/ApLogService.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserDeleteJobData } from '../types.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountProcessorService {
|
||||
|
@ -45,6 +45,48 @@ export class DeleteAccountProcessorService {
|
|||
@Inject(DI.noteScheduleRepository)
|
||||
private noteScheduleRepository: NoteScheduleRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private readonly followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private readonly followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private readonly blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private readonly mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.clipsRepository)
|
||||
private readonly clipsRepository: ClipsRepository,
|
||||
|
||||
@Inject(DI.clipNotesRepository)
|
||||
private readonly clipNotesRepository: ClipNotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private readonly latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
@Inject(DI.noteEditRepository)
|
||||
private readonly noteEditRepository: NoteEditRepository,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private readonly noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
@Inject(DI.pollVotesRepository)
|
||||
private readonly pollVotesRepository: PollVotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private readonly pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.signinsRepository)
|
||||
private readonly signinsRepository: SigninsRepository,
|
||||
|
||||
@Inject(DI.userIpsRepository)
|
||||
private readonly userIpsRepository: UserIpsRepository,
|
||||
|
||||
@Inject(DI.registryItemsRepository)
|
||||
private readonly registryItemsRepository: RegistryItemsRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private driveService: DriveService,
|
||||
private emailService: EmailService,
|
||||
|
@ -65,6 +107,140 @@ export class DeleteAccountProcessorService {
|
|||
return;
|
||||
}
|
||||
|
||||
{ // Delete user clips
|
||||
const userClips = await this.clipsRepository.find({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
}) as { id: string }[];
|
||||
|
||||
// Delete one-at-a-time because there can be a lot
|
||||
for (const clip of userClips) {
|
||||
await this.clipNotesRepository.delete({
|
||||
id: clip.id,
|
||||
});
|
||||
}
|
||||
|
||||
await this.clipsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
this.logger.succ('All clips have been deleted.');
|
||||
}
|
||||
|
||||
{ // Delete favorites
|
||||
await this.noteFavoritesRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
this.logger.succ('All favorites have been deleted.');
|
||||
}
|
||||
|
||||
{ // Delete user relations
|
||||
await this.followingsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
|
||||
await this.followingsRepository.delete({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
await this.blockingsRepository.delete({
|
||||
blockerId: user.id,
|
||||
});
|
||||
|
||||
await this.blockingsRepository.delete({
|
||||
blockeeId: user.id,
|
||||
});
|
||||
|
||||
await this.mutingsRepository.delete({
|
||||
muterId: user.id,
|
||||
});
|
||||
|
||||
await this.mutingsRepository.delete({
|
||||
muteeId: user.id,
|
||||
});
|
||||
|
||||
this.logger.succ('All user relations have been deleted.');
|
||||
}
|
||||
|
||||
{ // Delete reactions
|
||||
let cursor: MiNoteReaction['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const reactions = await this.noteReactionsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
relations: {
|
||||
note: true,
|
||||
},
|
||||
}) as MiNoteReaction[];
|
||||
|
||||
if (reactions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = reactions.at(-1)?.id ?? null;
|
||||
|
||||
for (const reaction of reactions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const note = reaction.note!;
|
||||
await this.reactionService.delete(user, note, reaction);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ('All reactions have been deleted');
|
||||
}
|
||||
|
||||
{ // Poll votes
|
||||
let cursor: MiNoteReaction['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const votes = await this.pollVotesRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
}) as { id: string }[];
|
||||
|
||||
if (votes.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = votes.at(-1)?.id ?? null;
|
||||
|
||||
await this.pollVotesRepository.delete({
|
||||
id: In(votes.map(v => v.id)),
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.succ('All poll votes have been deleted');
|
||||
}
|
||||
|
||||
{ // Delete scheduled notes
|
||||
const scheduledNotes = await this.noteScheduleRepository.findBy({
|
||||
userId: user.id,
|
||||
|
@ -82,6 +258,10 @@ export class DeleteAccountProcessorService {
|
|||
}
|
||||
|
||||
{ // Delete notes
|
||||
await this.latestNotesRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
let cursor: MiNote['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
|
@ -102,7 +282,23 @@ export class DeleteAccountProcessorService {
|
|||
|
||||
cursor = notes.at(-1)?.id ?? null;
|
||||
|
||||
await this.notesRepository.delete(notes.map(note => note.id));
|
||||
// Delete associated polls one-at-a-time, since it can cascade to a LOT of vote entries
|
||||
for (const note of notes) {
|
||||
if (note.hasPoll) {
|
||||
await this.pollsRepository.delete({
|
||||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ids = notes.map(note => note.id);
|
||||
|
||||
await this.noteEditRepository.delete({
|
||||
noteId: In(ids),
|
||||
});
|
||||
await this.notesRepository.delete({
|
||||
id: In(ids),
|
||||
});
|
||||
|
||||
for (const note of notes) {
|
||||
await this.searchService.unindexNote(note);
|
||||
|
@ -119,37 +315,6 @@ export class DeleteAccountProcessorService {
|
|||
this.logger.succ('All of notes deleted');
|
||||
}
|
||||
|
||||
{ // Delete reactions
|
||||
let cursor: MiNoteReaction['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const reactions = await this.noteReactionsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
}) as MiNoteReaction[];
|
||||
|
||||
if (reactions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = reactions.at(-1)?.id ?? null;
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote;
|
||||
|
||||
await this.reactionService.delete(user, note);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ('All reactions have been deleted');
|
||||
}
|
||||
|
||||
{ // Delete files
|
||||
let cursor: MiDriveFile['id'] | null = null;
|
||||
|
||||
|
@ -191,20 +356,42 @@ export class DeleteAccountProcessorService {
|
|||
this.logger.succ('All AP logs deleted');
|
||||
}
|
||||
|
||||
{ // Send email notification
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
if (profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, 'Account deleted',
|
||||
'Your account has been deleted.',
|
||||
'Your account has been deleted.');
|
||||
// Do this BEFORE deleting the account!
|
||||
const profile = await this.userProfilesRepository.findOneBy({ userId: user.id });
|
||||
|
||||
{ // Delete the actual account
|
||||
await this.userIpsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await this.signinsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await this.registryItemsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// soft指定されている場合は物理削除しない
|
||||
if (job.data.soft) {
|
||||
// nop
|
||||
} else {
|
||||
await this.usersRepository.delete(user.id);
|
||||
}
|
||||
|
||||
this.logger.succ('Account data deleted');
|
||||
}
|
||||
|
||||
// soft指定されている場合は物理削除しない
|
||||
if (job.data.soft) {
|
||||
// nop
|
||||
} else {
|
||||
await this.usersRepository.delete(job.data.user.id);
|
||||
{ // Send email notification
|
||||
if (profile && profile.email && profile.emailVerified) {
|
||||
try {
|
||||
await this.emailService.sendEmail(profile.email, 'Account deleted',
|
||||
'Your account has been deleted.',
|
||||
'Your account has been deleted.');
|
||||
} catch (e) {
|
||||
this.logger.warn('Failed to send account deletion message:', { e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Account deleted';
|
||||
|
|
|
@ -675,9 +675,11 @@ export class FileServerService {
|
|||
if (info.blocked) {
|
||||
reply.code(429);
|
||||
reply.send({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
error: {
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
},
|
||||
});
|
||||
|
||||
return false;
|
||||
|
|
|
@ -445,6 +445,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
translationTimeout: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
deeplAuthKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
@ -477,6 +481,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
defaultLike: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
@ -741,6 +749,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
objectStorageUseProxy: instance.objectStorageUseProxy,
|
||||
objectStorageSetPublicRead: instance.objectStorageSetPublicRead,
|
||||
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
|
||||
translationTimeout: instance.translationTimeout,
|
||||
deeplAuthKey: instance.deeplAuthKey,
|
||||
deeplIsPro: instance.deeplIsPro,
|
||||
deeplFreeMode: instance.deeplFreeMode,
|
||||
|
|
|
@ -12,6 +12,7 @@ import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
@ -186,6 +187,36 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
followStats: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
totalFollowing: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
totalFollowers: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localFollowing: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localFollowers: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
remoteFollowing: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
remoteFollowers: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -213,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private roleService: RoleService,
|
||||
private roleEntityService: RoleEntityService,
|
||||
private idService: IdService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const [user, profile] = await Promise.all([
|
||||
|
@ -237,6 +269,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const roleAssigns = await this.roleService.getUserAssigns(user.id);
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
|
||||
const followStats = await this.cacheService.getFollowStats(user.id);
|
||||
|
||||
return {
|
||||
email: profile.email,
|
||||
emailVerified: profile.emailVerified,
|
||||
|
@ -269,6 +303,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
|
||||
roleId: a.roleId,
|
||||
})),
|
||||
followStats: {
|
||||
...followStats,
|
||||
totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers),
|
||||
totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ export const paramDef = {
|
|||
description: { type: 'string', nullable: true },
|
||||
defaultLightTheme: { type: 'string', nullable: true },
|
||||
defaultDarkTheme: { type: 'string', nullable: true },
|
||||
defaultLike: { type: 'string', nullable: true },
|
||||
defaultLike: { type: 'string' },
|
||||
cacheRemoteFiles: { type: 'boolean' },
|
||||
cacheRemoteSensitiveFiles: { type: 'boolean' },
|
||||
emailRequiredForSignup: { type: 'boolean' },
|
||||
|
@ -103,6 +103,7 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
translationTimeout: { type: 'number' },
|
||||
deeplAuthKey: { type: 'string', nullable: true },
|
||||
deeplIsPro: { type: 'boolean' },
|
||||
deeplFreeMode: { type: 'boolean' },
|
||||
|
@ -571,6 +572,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle;
|
||||
}
|
||||
|
||||
if (ps.translationTimeout !== undefined) {
|
||||
set.translationTimeout = ps.translationTimeout;
|
||||
}
|
||||
|
||||
if (ps.deeplAuthKey !== undefined) {
|
||||
if (ps.deeplAuthKey === '') {
|
||||
set.deeplAuthKey = null;
|
||||
|
|
|
@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// ランキング更新
|
||||
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
|
||||
await this.featuredService.updateGalleryPostsRanking(post.id, 1);
|
||||
await this.featuredService.updateGalleryPostsRanking(post, 1);
|
||||
}
|
||||
|
||||
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);
|
||||
|
|
|
@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// ランキング更新
|
||||
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
|
||||
await this.featuredService.updateGalleryPostsRanking(post.id, -1);
|
||||
await this.featuredService.updateGalleryPostsRanking(post, -1);
|
||||
}
|
||||
|
||||
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);
|
||||
|
|
|
@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js";
|
|||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: false,
|
||||
|
@ -41,6 +42,7 @@ export const paramDef = {
|
|||
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
|
||||
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
|
||||
trending: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['tag', 'sort'],
|
||||
} as const;
|
||||
|
@ -52,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
|
@ -80,7 +83,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break;
|
||||
}
|
||||
|
||||
const users = await query.limit(ps.limit).getMany();
|
||||
let users = await query.limit(ps.limit).getMany();
|
||||
|
||||
// This is not ideal, for a couple of reasons:
|
||||
// 1. It may return less than "limit" results.
|
||||
// 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
|
||||
// Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
|
||||
if (ps.trending) {
|
||||
const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
|
||||
users = usersWithRoles
|
||||
.filter(([,p]) => p.canTrend)
|
||||
.map(([u]) => u);
|
||||
}
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
|
||||
});
|
||||
|
|
|
@ -117,7 +117,8 @@ 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');
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.andWhere('user.isExplorable = TRUE');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
|
|
@ -10,22 +10,28 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import type { MiMeta, MiNote } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { hasText } from '@/models/Note.js';
|
||||
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
|
||||
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,
|
||||
kind: 'read:account',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
sourceLang: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
sourceLang: { type: 'string', optional: true, nullable: false },
|
||||
text: { type: 'string', optional: true, nullable: false },
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -45,6 +51,11 @@ export const meta = {
|
|||
code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE',
|
||||
id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d',
|
||||
},
|
||||
translationFailed: {
|
||||
message: 'Failed to translate note. Please try again later or contact an administrator for assistance.',
|
||||
code: 'TRANSLATION_FAILED',
|
||||
id: '4e7a1a4f-521c-4ba2-b10a-69e5e2987b2f',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
|
@ -73,6 +84,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private getterService: GetterService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private roleService: RoleService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly loggerService: ApiLoggerService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
@ -89,8 +102,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
|
||||
}
|
||||
|
||||
if (note.text == null) {
|
||||
return;
|
||||
if (!hasText(note)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance;
|
||||
|
@ -101,13 +114,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
let targetLang = ps.targetLang;
|
||||
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
|
||||
|
||||
let response = await this.cacheService.getCachedTranslation(note, targetLang);
|
||||
if (!response) {
|
||||
this.loggerService.logger.debug(`Fetching new translation for note=${note.id} lang=${targetLang}`);
|
||||
response = await this.fetchTranslation(note, targetLang);
|
||||
if (!response) {
|
||||
throw new ApiError(meta.errors.translationFailed);
|
||||
}
|
||||
|
||||
await this.cacheService.setCachedTranslation(note, targetLang, response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchTranslation(note: MiNote & { text: string }, targetLang: string) {
|
||||
// Load-bearing try/catch - removing this will shift indentation and cause ~80 lines of upstream merge conflicts
|
||||
try {
|
||||
// Ignore deeplFreeInstance unless deeplFreeMode is set
|
||||
const deeplFreeInstance = this.serverSettings.deeplFreeMode ? this.serverSettings.deeplFreeInstance : null;
|
||||
|
||||
// DeepL/DeepLX handling
|
||||
if (canDeepl) {
|
||||
if (this.serverSettings.deeplAuthKey || deeplFreeInstance) {
|
||||
const params = new URLSearchParams();
|
||||
if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
|
||||
params.append('text', note.text);
|
||||
params.append('target_lang', targetLang);
|
||||
const endpoint = canDeeplFree ? this.serverSettings.deeplFreeInstance as string : 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',
|
||||
|
@ -116,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
Accept: 'application/json, */*',
|
||||
},
|
||||
body: params.toString(),
|
||||
timeout: this.serverSettings.translationTimeout,
|
||||
});
|
||||
if (this.serverSettings.deeplAuthKey) {
|
||||
const json = (await res.json()) as {
|
||||
|
@ -151,8 +185,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
// LibreTranslate handling
|
||||
if (canLibre) {
|
||||
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL as string, {
|
||||
if (this.serverSettings.libreTranslateURL) {
|
||||
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -165,6 +199,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
format: 'text',
|
||||
api_key: this.serverSettings.libreTranslateKey ?? '',
|
||||
}),
|
||||
timeout: this.serverSettings.translationTimeout,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
@ -182,8 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
text: json.translatedText,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
this.loggerService.logger.error('Unhandled error from translation API: ', { e });
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,11 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { MiFollowing } from '@/models/_.js';
|
||||
import type { MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
@ -38,7 +41,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
sort: { type: 'string', enum: ['+follower', '-follower', '+localFollower', '-localFollower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
|
||||
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
|
||||
hostname: {
|
||||
|
@ -59,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private userEntityService: UserEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
|
@ -81,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
switch (ps.sort) {
|
||||
case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
|
||||
case '-follower': query.orderBy('user.followersCount', 'ASC'); break;
|
||||
case '+localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'DESC'); break;
|
||||
case '-localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'ASC'); break;
|
||||
case '+createdAt': query.orderBy('user.id', 'DESC'); break;
|
||||
case '-createdAt': query.orderBy('user.id', 'ASC'); break;
|
||||
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
|
||||
|
@ -94,9 +100,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
query.limit(ps.limit);
|
||||
query.offset(ps.offset);
|
||||
|
||||
const users = await query.getMany();
|
||||
const allUsers = await query.getMany();
|
||||
|
||||
// This is not ideal, for a couple of reasons:
|
||||
// 1. It may return less than "limit" results.
|
||||
// 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
|
||||
// Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
|
||||
const usersWithRoles = await Promise.all(allUsers.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
|
||||
const users = usersWithRoles
|
||||
.filter(([,p]) => p.canTrend)
|
||||
.map(([u]) => u);
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
|
||||
});
|
||||
}
|
||||
|
||||
private addLocalFollowers(query: SelectQueryBuilder<MiUser>) {
|
||||
query.innerJoin(qb => {
|
||||
return qb
|
||||
.from(MiFollowing, 'f')
|
||||
.addSelect('f."followeeId"')
|
||||
.addSelect('COUNT(*) FILTER (where f."followerHost" IS NULL)', 'localFollowers')
|
||||
.addSelect('COUNT(*) FILTER (where f."followeeHost" IS NOT NULL)', 'remoteFollowers')
|
||||
.groupBy('"followeeId"');
|
||||
}, 'f', 'user.id = f."followeeId"');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -890,6 +890,7 @@ export class ClientServerService {
|
|||
return await reply.view('info-card', {
|
||||
version: this.config.version,
|
||||
host: this.config.host,
|
||||
url: this.config.url,
|
||||
meta: this.meta,
|
||||
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
|
||||
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
|
||||
|
|
|
@ -15,15 +15,21 @@ import type Logger from '@/logger.js';
|
|||
import { query } from '@/misc/prelude/url.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import type { MiAccessToken, NotesRepository } from '@/models/_.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
||||
import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js';
|
||||
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||
import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export type LocalSummalyResult = SummalyResult & {
|
||||
|
@ -31,7 +37,27 @@ export type LocalSummalyResult = SummalyResult & {
|
|||
};
|
||||
|
||||
// Increment this to invalidate cached previews after a major change.
|
||||
const cacheFormatVersion = 2;
|
||||
const cacheFormatVersion = 3;
|
||||
|
||||
type PreviewRoute = {
|
||||
Querystring: {
|
||||
url?: string
|
||||
lang?: string,
|
||||
fetch?: string,
|
||||
i?: string,
|
||||
},
|
||||
};
|
||||
|
||||
type AuthArray = [user: MiLocalUser | null | undefined, app: MiAccessToken | null | undefined, actor: MiLocalUser | string];
|
||||
|
||||
// Up to 50 requests, then 10 / second (at 2 / 200ms rate)
|
||||
const previewLimit: Keyed<BucketRateLimit> = {
|
||||
key: '/url',
|
||||
type: 'bucket',
|
||||
size: 50,
|
||||
dripSize: 2,
|
||||
dripRate: 200,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UrlPreviewService {
|
||||
|
@ -58,6 +84,9 @@ export class UrlPreviewService {
|
|||
private readonly apDbResolverService: ApDbResolverService,
|
||||
private readonly apRequestService: ApRequestService,
|
||||
private readonly systemAccountService: SystemAccountService,
|
||||
private readonly apNoteService: ApNoteService,
|
||||
private readonly authenticateService: AuthenticateService,
|
||||
private readonly rateLimiterService: SkRateLimiterService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('url-preview');
|
||||
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
|
||||
|
@ -85,9 +114,9 @@ export class UrlPreviewService {
|
|||
|
||||
@bindThis
|
||||
public async handle(
|
||||
request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>,
|
||||
request: FastifyRequest<PreviewRoute>,
|
||||
reply: FastifyReply,
|
||||
): Promise<object | undefined> {
|
||||
): Promise<void> {
|
||||
const url = request.query.url;
|
||||
if (typeof url !== 'string' || !URL.canParse(url)) {
|
||||
reply.code(400);
|
||||
|
@ -101,38 +130,39 @@ export class UrlPreviewService {
|
|||
}
|
||||
|
||||
if (!this.meta.urlPreviewEnabled) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
message: 'URL preview is disabled',
|
||||
code: 'URL_PREVIEW_DISABLED',
|
||||
id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const auth = await this.authenticate(request);
|
||||
if (!await this.checkRateLimit(auth, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
message: 'URL is blocked',
|
||||
code: 'URL_PREVIEW_BLOCKED',
|
||||
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fetch = !!request.query.fetch;
|
||||
if (fetch && !await this.checkFetchPermissions(auth, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
|
||||
const cached = await this.previewCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
// Cache 1 day (matching redis)
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (cached.activityPub) {
|
||||
cached.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(cached.activityPub);
|
||||
}
|
||||
|
||||
return cached;
|
||||
if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -144,14 +174,13 @@ export class UrlPreviewService {
|
|||
|
||||
// Repeat check, since redirects are allowed.
|
||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
message: 'URL is blocked',
|
||||
code: 'URL_PREVIEW_BLOCKED',
|
||||
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
|
||||
|
@ -164,33 +193,76 @@ export class UrlPreviewService {
|
|||
await this.inferActivityPubLink(summary);
|
||||
}
|
||||
|
||||
if (summary.activityPub) {
|
||||
if (summary.activityPub && !summary.haveNoteLocally) {
|
||||
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
||||
summary.haveNoteLocally ||= !!await this.apDbResolverService.getNoteFromApId(summary.activityPub);
|
||||
const exists = await this.noteExists(summary.activityPub, fetch);
|
||||
|
||||
// Remove the AP flag if we encounter a permanent error fetching the note.
|
||||
if (exists === false) {
|
||||
summary.activityPub = null;
|
||||
summary.haveNoteLocally = undefined;
|
||||
} else {
|
||||
summary.haveNoteLocally = exists ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// Await this to avoid hammering redis when a bunch of URLs are fetched at once
|
||||
await this.previewCache.set(cacheKey, summary);
|
||||
|
||||
// Cache 1 day (matching redis)
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
// Cache 1 day (matching redis), but only once we finalize the result
|
||||
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
return summary;
|
||||
return reply.code(200).send(summary);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
|
||||
|
||||
reply.code(422);
|
||||
reply.header('Cache-Control', 'max-age=3600');
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(422).send({
|
||||
error: {
|
||||
message: 'Failed to get preview',
|
||||
code: 'URL_PREVIEW_FAILED',
|
||||
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendCachedPreview(cacheKey: string, reply: FastifyReply, fetch: boolean): Promise<boolean> {
|
||||
const summary = await this.previewCache.get(cacheKey);
|
||||
if (summary === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if note has loaded since we last cached the preview
|
||||
if (summary.activityPub && !summary.haveNoteLocally) {
|
||||
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
||||
const exists = await this.noteExists(summary.activityPub, fetch);
|
||||
|
||||
// Remove the AP flag if we encounter a permanent error fetching the note.
|
||||
if (exists === false) {
|
||||
summary.activityPub = null;
|
||||
summary.haveNoteLocally = undefined;
|
||||
} else {
|
||||
summary.haveNoteLocally = exists ?? false;
|
||||
}
|
||||
|
||||
// Persist the result once we finalize the result
|
||||
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||
await this.previewCache.set(cacheKey, summary);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache 1 day (matching redis), but only once we finalize the result
|
||||
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
reply.code(200).send(summary);
|
||||
return true;
|
||||
}
|
||||
|
||||
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||
const agent = this.config.proxy
|
||||
? {
|
||||
|
@ -211,6 +283,7 @@ export class UrlPreviewService {
|
|||
}
|
||||
|
||||
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const proxy = meta.urlPreviewSummaryProxyUrl!;
|
||||
const queryStr = query({
|
||||
followRedirects: true,
|
||||
|
@ -302,4 +375,129 @@ export class UrlPreviewService {
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// true = exists, false = does not exist (permanently), null = does not exist (temporarily)
|
||||
private async noteExists(uri: string, fetch = false): Promise<boolean | null> {
|
||||
try {
|
||||
// Local note or cached remote note
|
||||
if (await this.apDbResolverService.getNoteFromApId(uri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Un-cached remote note
|
||||
if (!fetch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Newly cached remote note
|
||||
if (await this.apNoteService.resolveNote(uri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-existent or deleted note
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Errors, including invalid notes and network errors
|
||||
return isRetryableError(err) ? null : false;
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from ApiCallService
|
||||
private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise<AuthArray> {
|
||||
const body = request.method === 'GET' ? request.query : request.body;
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
|
||||
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||
? request.headers.authorization.slice(7)
|
||||
: body?.['i'];
|
||||
if (token != null && typeof token !== 'string') {
|
||||
return [undefined, undefined, getIpHash(request.ip)];
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await this.authenticateService.authenticate(token);
|
||||
return [auth[0], auth[1], auth[0] ?? getIpHash(request.ip)];
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
return [undefined, undefined, getIpHash(request.ip)];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from ApiCallService
|
||||
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||
const [user, app] = auth;
|
||||
|
||||
// Authentication
|
||||
if (user === undefined) {
|
||||
reply.code(401).send({
|
||||
error: {
|
||||
message: 'Authentication failed. Please ensure your token is correct.',
|
||||
code: 'AUTHENTICATION_FAILED',
|
||||
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (user === null) {
|
||||
reply.code(401).send({
|
||||
error: {
|
||||
message: 'Credential required.',
|
||||
code: 'CREDENTIAL_REQUIRED',
|
||||
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Authorization
|
||||
if (user.isSuspended || user.isDeleted) {
|
||||
reply.code(403).send({
|
||||
error: {
|
||||
message: 'Your account has been suspended.',
|
||||
code: 'YOUR_ACCOUNT_SUSPENDED',
|
||||
kind: 'permission',
|
||||
|
||||
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (app && !app.permission.includes('read:account')) {
|
||||
reply.code(403).send({
|
||||
error: {
|
||||
message: 'Your app does not have the necessary permissions to use this endpoint.',
|
||||
code: 'PERMISSION_DENIED',
|
||||
kind: 'permission',
|
||||
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||
const info = await this.rateLimiterService.limit(previewLimit, auth[2]);
|
||||
|
||||
// Always send headers, even if not blocked
|
||||
sendRateLimitHeaders(reply, info);
|
||||
|
||||
if (info.blocked) {
|
||||
reply.code(429).send({
|
||||
error: {
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
},
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ html
|
|||
}
|
||||
|
||||
body
|
||||
a#a(href=`https://${host}` target="_blank")
|
||||
a#a(href=url target="_blank")
|
||||
header#banner(style=`background-image: url(${meta.bannerUrl})`)
|
||||
div#title= meta.name || host
|
||||
div#content
|
||||
|
|
113
packages/backend/test/unit/core/HttpRequestService.ts
Normal file
113
packages/backend/test/unit/core/HttpRequestService.ts
Normal file
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import type { Mock } from 'jest-mock';
|
||||
import type { PrivateNetwork } from '@/config.js';
|
||||
import type { Socket } from 'net';
|
||||
import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js';
|
||||
import { parsePrivateNetworks } from '@/config.js';
|
||||
|
||||
describe(HttpRequestService, () => {
|
||||
let allowedPrivateNetworks: PrivateNetwork[] | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
allowedPrivateNetworks = parsePrivateNetworks([
|
||||
'10.0.0.1/32',
|
||||
{ network: '127.0.0.1/32', ports: [1] },
|
||||
{ network: '127.0.0.1/32', ports: [3, 4, 5] },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('isPrivateIp', () => {
|
||||
it('should return false when ip public', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false when ip private and port matches', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false when ip private and all ports undefined', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when ip private and no ports specified', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true when ip private and port does not match', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true when ip private and port is null but ports are specified', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSocketConnect', () => {
|
||||
let fakeSocket: Socket;
|
||||
let fakeSocketMutable: {
|
||||
remoteAddress: string | undefined;
|
||||
remotePort: number | undefined;
|
||||
destroy: Mock<(error?: Error) => void>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fakeSocketMutable = {
|
||||
remoteAddress: '74.125.127.100',
|
||||
remotePort: 80,
|
||||
destroy: jest.fn<(error?: Error) => void>(),
|
||||
};
|
||||
fakeSocket = fakeSocketMutable as unknown as Socket;
|
||||
});
|
||||
|
||||
it('should accept when IP is empty', () => {
|
||||
fakeSocketMutable.remoteAddress = undefined;
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept when IP is invalid', () => {
|
||||
fakeSocketMutable.remoteAddress = 'AB939ajd9jdajsdja8jj';
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept when IP is valid', () => {
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept when IP is private and port match', () => {
|
||||
fakeSocketMutable.remoteAddress = '127.0.0.1';
|
||||
fakeSocketMutable.remotePort = 1;
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject when IP is private and port no match', () => {
|
||||
fakeSocketMutable.remoteAddress = '127.0.0.1';
|
||||
fakeSocketMutable.remotePort = 2;
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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.13",
|
||||
"@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.4",
|
||||
"vue": "3.5.13"
|
||||
"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.13",
|
||||
"@vue/runtime-core": "3.5.14",
|
||||
"acorn": "8.14.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
|
|
|
@ -176,6 +176,7 @@ export const ROLE_POLICIES = [
|
|||
'canImportMuting',
|
||||
'canImportUserLists',
|
||||
'chatAvailability',
|
||||
'canTrend',
|
||||
] as const;
|
||||
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse'];
|
||||
|
|
|
@ -59,6 +59,7 @@ export class I18n<T extends ILocale> {
|
|||
if (typeof value === 'string') {
|
||||
const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter);
|
||||
|
||||
// TODO add a flag to suppress this warning from uses of <I18n> component
|
||||
if (parameters.length) {
|
||||
console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
|
||||
}
|
||||
|
|
|
@ -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.13",
|
||||
"@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.4",
|
||||
"vue": "3.5.13",
|
||||
"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.13",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"@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",
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button
|
||||
v-if="!link"
|
||||
ref="el" class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</button>
|
||||
<MkA
|
||||
v-else class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:to="to ?? '#'"
|
||||
:behavior="linkBehavior"
|
||||
@mousedown="onMousedown"
|
||||
|
@ -48,6 +48,7 @@ const props = defineProps<{
|
|||
linkBehavior?: null | 'window' | 'browser';
|
||||
autofocus?: boolean;
|
||||
wait?: boolean;
|
||||
accent?: boolean;
|
||||
danger?: boolean;
|
||||
full?: boolean;
|
||||
small?: boolean;
|
||||
|
@ -234,6 +235,24 @@ function onMousedown(evt: MouseEvent): void {
|
|||
}
|
||||
}
|
||||
|
||||
&.accent {
|
||||
font-weight: bold;
|
||||
color: var(--MI_THEME-accent);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
background: var(--MI_THEME-accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
font-weight: bold;
|
||||
color: var(--MI_THEME-error);
|
||||
|
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@leave="leave"
|
||||
@afterLeave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
|
||||
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted, [$style.naked]: naked }]">
|
||||
<slot></slot>
|
||||
<button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
|
||||
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
|
||||
|
@ -228,6 +228,11 @@ onUnmounted(() => {
|
|||
*/
|
||||
background: var(--MI_THEME-panel);
|
||||
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.omitted {
|
||||
position: relative;
|
||||
max-height: var(--maxHeight);
|
||||
|
|
|
@ -86,13 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:isBlock="true"
|
||||
class="_selectable"
|
||||
/>
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else-if="translation">
|
||||
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
|
||||
</div>
|
||||
</div>
|
||||
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
|
||||
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
|
||||
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
</div>
|
||||
|
@ -101,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
|
||||
|
@ -160,10 +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 ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
|
||||
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.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" @click.stop="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
|
@ -171,24 +168,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</article>
|
||||
</div>
|
||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
|
@ -204,7 +184,7 @@ import * as mfm from '@transfem-org/sfm-js';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
|
||||
import type { Ref } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
@ -224,7 +204,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
|||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
@ -236,7 +216,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
|||
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
|
||||
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
|
@ -246,13 +226,17 @@ 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 { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { instance, isEnabledUrlPreview } from '@/instance.js';
|
||||
import { focusPrev, focusNext } from '@/utility/focus.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
|
||||
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
|
||||
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -272,8 +256,6 @@ const emit = defineEmits<{
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
|
@ -322,15 +304,14 @@ const galleryEl = useTemplateRef('galleryEl');
|
|||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
|
||||
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
|
||||
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
|
||||
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
||||
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
|
||||
|
@ -347,38 +328,13 @@ const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
|
||||
*/
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) return result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) return replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) return renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let renoting = false;
|
||||
|
||||
const keymap = {
|
||||
|
@ -403,6 +359,11 @@ const keymap = {
|
|||
if (!prefer.s.showClipButtonInNoteFooter) return;
|
||||
clip();
|
||||
},
|
||||
't': () => {
|
||||
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
|
||||
translate();
|
||||
}
|
||||
},
|
||||
'o': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
galleryEl.value?.openGallery();
|
||||
|
@ -825,6 +786,12 @@ async function clip(): Promise<void> {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
if (props.mock) return;
|
||||
|
||||
await translateNote(appearNote.value.id, translation, translating);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
|
@ -1199,13 +1166,6 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.urlPreview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
|
|
@ -104,13 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
class="_selectable"
|
||||
/>
|
||||
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else-if="translation">
|
||||
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
|
||||
</div>
|
||||
</div>
|
||||
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
|
||||
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
|
||||
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
|
@ -118,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
|
||||
</div>
|
||||
|
@ -172,10 +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 ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
|
||||
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.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="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
|
@ -230,11 +227,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -243,7 +236,7 @@ import { computed, inject, onMounted, provide, ref, useTemplateRef, watch } from
|
|||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
|
@ -260,7 +253,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
@ -271,7 +264,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js';
|
|||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
|
||||
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
|
||||
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
|
@ -285,11 +278,15 @@ 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 { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { instance, isEnabledUrlPreview } from '@/instance.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
|
||||
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
|
||||
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -340,12 +337,12 @@ const isMyRenote = $i && ($i.id === note.value.userId);
|
|||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
|
||||
const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
|
||||
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
|
||||
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
|
||||
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
|
@ -358,6 +355,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
|||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
watch(() => props.expandAllCws, (expandAllCws) => {
|
||||
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
|
||||
});
|
||||
|
@ -376,7 +375,7 @@ let renoting = false;
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
const keymap = {
|
||||
|
@ -388,6 +387,11 @@ const keymap = {
|
|||
if (!prefer.s.showClipButtonInNoteFooter) return;
|
||||
clip();
|
||||
},
|
||||
't': () => {
|
||||
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
|
||||
translate();
|
||||
}
|
||||
},
|
||||
'o': () => galleryEl.value?.openGallery(),
|
||||
'v|enter': () => {
|
||||
if (appearNote.value.cw != null) {
|
||||
|
@ -766,6 +770,10 @@ async function clip(): Promise<void> {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
await translateNote(appearNote.value.id, translation, translating);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
|
@ -1044,13 +1052,6 @@ function animatedMFM() {
|
|||
color: var(--MI_THEME-renote);
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.poll {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
|
|
@ -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 && $i?.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>
|
||||
|
@ -73,19 +79,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</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 { host } from '@@/js/config.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';
|
||||
|
@ -99,16 +102,18 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
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 } from '@/instance';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -126,12 +131,12 @@ const props = withDefaults(defineProps<{
|
|||
const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
|
||||
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const muted = computed(() => $i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
|
||||
const translation = ref<any>(null);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
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>();
|
||||
|
@ -154,9 +159,11 @@ const isRenote = (
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
|
||||
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;
|
||||
|
@ -170,6 +177,8 @@ async function removeReply(id: Misskey.entities.Note['id']) {
|
|||
}
|
||||
}
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: appearNote,
|
||||
|
@ -378,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,
|
||||
|
|
|
@ -240,13 +240,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;
|
||||
|
|
|
@ -18,10 +18,10 @@ 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>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
</MkPagination>
|
||||
|
|
|
@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
import number from '@/filters/number.js';
|
||||
import { prefer } from '@/preferences';
|
||||
|
||||
const props = defineProps<{
|
||||
value: number;
|
||||
|
@ -36,7 +37,11 @@ watch(() => props.value, (to, from) => {
|
|||
}
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
if (prefer.s.animation) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
tweened.number = to;
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
|
|
@ -274,18 +274,16 @@ const fetchMore = async (): Promise<void> => {
|
|||
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
});
|
||||
await reverseConcat(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
});
|
||||
await reverseConcat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
|
|
|
@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import { sum } from '@/utility/array.js';
|
||||
|
@ -72,7 +72,7 @@ const showResult = ref(props.readOnly || isVoted.value);
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${props.noteId}`,
|
||||
url: `${config.url}/notes/${props.noteId}`,
|
||||
}));
|
||||
|
||||
// 期限付きアンケート
|
||||
|
|
|
@ -14,7 +14,7 @@ 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"/>
|
||||
<slot v-if="hasMoreReactions" :key="'$more'" name="more"/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js';
|
|||
const props = withDefaults(defineProps<{
|
||||
role: Misskey.entities.Role;
|
||||
forModeration: boolean;
|
||||
detailed: boolean;
|
||||
detailed?: boolean;
|
||||
}>(), {
|
||||
detailed: true,
|
||||
});
|
||||
|
|
|
@ -12,13 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/>
|
||||
<MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
|
||||
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
<div v-if="note.text && translating || note.text && translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else>
|
||||
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
|
||||
</div>
|
||||
</div>
|
||||
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
|
||||
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA>
|
||||
</div>
|
||||
<details v-if="note.files && note.files.length > 0" :open="!prefer.s.collapseFiles && !hideFiles">
|
||||
|
@ -51,14 +45,20 @@ import * as os from '@/os.js';
|
|||
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
|
||||
import { useRouter } from '@/router';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
translating?: boolean;
|
||||
translation?: any;
|
||||
translation?: Misskey.entities.NotesTranslateResponse | false | null;
|
||||
hideFiles?: boolean;
|
||||
expandAllCws?: boolean;
|
||||
}>();
|
||||
}>(), {
|
||||
translating: false,
|
||||
translation: null,
|
||||
hideFiles: false,
|
||||
expandAllCws: false,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -140,13 +140,6 @@ watch(() => props.expandAllCws, (expandAllCws) => {
|
|||
color: var(--MI_THEME-renote);
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.showLess {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
|
|
|
@ -19,7 +19,7 @@ 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-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">
|
||||
|
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
</MkPagination>
|
||||
|
|
|
@ -71,8 +71,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<div v-if="showAsQuote && activityPub && !theNote && !fetchingTheNote" :class="$style.action">
|
||||
<MkButton :small="true" inline @click="fetchNote()">
|
||||
<div v-if="showAsQuote && activityPub && !theNote && $i" :class="$style.action">
|
||||
<MkButton :small="true" :disabled="!!fetching || fetchingTheNote" inline @click="() => refresh(true)">
|
||||
<i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }}
|
||||
</MkButton>
|
||||
</div>
|
||||
|
@ -93,6 +93,7 @@ import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
|
|||
import { url as local } from '@@/js/config.js';
|
||||
import { versatileLang } from '@@/js/intl-const.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { maybeMakeRelative } from '@@/js/url.js';
|
||||
import type { summaly } from '@misskey-dev/summaly';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
@ -104,7 +105,7 @@ import { prefer } from '@/preferences.js';
|
|||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
|
||||
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
|
||||
import { maybeMakeRelative } from '@@/js/url.js';
|
||||
import { $i } from '@/i';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
|
@ -131,7 +132,7 @@ const maybeRelativeUrl = maybeMakeRelative(props.url, local);
|
|||
const self = maybeRelativeUrl !== props.url;
|
||||
const attr = self ? 'to' : 'href';
|
||||
const target = self ? null : '_blank';
|
||||
const fetching = ref(true);
|
||||
const fetching = ref<Promise<void> | null>(null);
|
||||
const title = ref<string | null>(null);
|
||||
const description = ref<string | null>(null);
|
||||
const thumbnail = ref<string | null>(null);
|
||||
|
@ -139,11 +140,12 @@ const icon = ref<string | null>(null);
|
|||
const sitename = ref<string | null>(null);
|
||||
const sensitive = ref<boolean>(false);
|
||||
const activityPub = ref<string | null>(null);
|
||||
const player = ref({
|
||||
const player = ref<SummalyResult['player']>({
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
} as SummalyResult['player']);
|
||||
allow: [],
|
||||
});
|
||||
const playerEnabled = ref(false);
|
||||
const tweetId = ref<string | null>(null);
|
||||
const tweetExpanded = ref(props.detail);
|
||||
|
@ -173,14 +175,14 @@ async function fetchNote() {
|
|||
return;
|
||||
}
|
||||
theNote.value = response['object'];
|
||||
fetchingTheNote.value = false;
|
||||
} catch (err) {
|
||||
if (_DEV_) {
|
||||
console.error(`failed to extract note for preview of ${activityPub.value}`, err);
|
||||
}
|
||||
activityPub.value = null;
|
||||
fetchingTheNote.value = false;
|
||||
theNote.value = null;
|
||||
} finally {
|
||||
fetchingTheNote.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,39 +200,52 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
|
|||
|
||||
requestUrl.hash = '';
|
||||
|
||||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
if (_DEV_) {
|
||||
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
})
|
||||
.then((info: SummalyResult & { haveNoteLocally?: boolean } | null) => {
|
||||
if (!info || info.url == null) {
|
||||
fetching.value = false;
|
||||
unknownUrl.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
fetching.value = false;
|
||||
unknownUrl.value = false;
|
||||
|
||||
title.value = info.title;
|
||||
description.value = info.description;
|
||||
thumbnail.value = info.thumbnail;
|
||||
icon.value = info.icon;
|
||||
sitename.value = info.sitename;
|
||||
player.value = info.player;
|
||||
sensitive.value = info.sensitive ?? false;
|
||||
activityPub.value = info.activityPub;
|
||||
if (info.haveNoteLocally) {
|
||||
fetchNote();
|
||||
}
|
||||
function refresh(withFetch = false) {
|
||||
const params = new URLSearchParams({
|
||||
url: requestUrl.href,
|
||||
lang: versatileLang,
|
||||
});
|
||||
if (withFetch) {
|
||||
params.set('fetch', 'true');
|
||||
}
|
||||
|
||||
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
|
||||
return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers })
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
if (_DEV_) {
|
||||
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
})
|
||||
.then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => {
|
||||
unknownUrl.value = info == null;
|
||||
title.value = info?.title ?? null;
|
||||
description.value = info?.description ?? null;
|
||||
thumbnail.value = info?.thumbnail ?? null;
|
||||
icon.value = info?.icon ?? null;
|
||||
sitename.value = info?.sitename ?? null;
|
||||
player.value = info?.player ?? {
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
};
|
||||
sensitive.value = info?.sensitive ?? false;
|
||||
activityPub.value = info?.activityPub ?? null;
|
||||
|
||||
theNote.value = null;
|
||||
if (info?.haveNoteLocally) {
|
||||
await fetchNote();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
fetching.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
function adjustTweetHeight(message: MessageEvent) {
|
||||
if (message.origin !== 'https://platform.twitter.com') return;
|
||||
|
@ -256,6 +271,9 @@ window.addEventListener('message', adjustTweetHeight);
|
|||
onUnmounted(() => {
|
||||
window.removeEventListener('message', adjustTweetHeight);
|
||||
});
|
||||
|
||||
// Load initial data
|
||||
refresh();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" @click="$emit('select', note.user)">
|
||||
<div v-if="!hardMuted" :class="$style.root" @click="$emit('select', note.user)">
|
||||
<div :class="$style.avatar">
|
||||
<MkAvatar :class="$style.icon" :user="note.user" indictor/>
|
||||
</div>
|
||||
|
@ -18,11 +18,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkA>
|
||||
</header>
|
||||
<div>
|
||||
<div v-if="isMuted" :class="[$style.text, $style.muted]">({{ i18n.ts.postFiltered }})</div>
|
||||
<div v-if="muted" :class="[$style.text, $style.muted]">
|
||||
<SkMutedNote :muted="muted" :note="note"></SkMutedNote>
|
||||
</div>
|
||||
<Mfm v-else :class="$style.text" :text="getNoteSummary(note)" :isBlock="true" :plain="true" :nowrap="false" :isNote="true" nyaize="respect" :author="note.user"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
|
||||
so MkNote create empty div instead of no elements
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -30,19 +38,19 @@ import * as Misskey from 'misskey-js';
|
|||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
note: Misskey.entities.Note,
|
||||
isMuted: boolean
|
||||
}>(), {
|
||||
isMuted: false,
|
||||
});
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(event: 'select', user: Misskey.entities.UserLite): void
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const { muted, hardMuted } = checkMutes(props.note);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template #default="{ items: notes }">
|
||||
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true">
|
||||
<SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
|
||||
<SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
</MkPagination>
|
||||
|
@ -18,16 +18,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { FollowingFeedTab } from '@/types/following-feed.js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import type { FollowingFeedTab } from '@/types/following-feed.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue';
|
||||
import { $i } from '@/i.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -78,37 +76,6 @@ const latestNotesPagination: Paging<'notes/following'> = {
|
|||
};
|
||||
|
||||
const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
function isSoftMuted(note: Misskey.entities.Note): boolean {
|
||||
return isMuted(note, $i?.mutedWords);
|
||||
}
|
||||
|
||||
function isHardMuted(note: Misskey.entities.Note): boolean {
|
||||
return isMuted(note, $i?.hardMutedWords);
|
||||
}
|
||||
|
||||
// Match the typing used by Misskey
|
||||
type Mutes = (string | string[])[] | null | undefined;
|
||||
|
||||
// Adapted from MkNote.ts
|
||||
function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean {
|
||||
return checkMute(note, mutes)
|
||||
|| checkMute(note.reply, mutes)
|
||||
|| checkMute(note.renote, mutes);
|
||||
}
|
||||
|
||||
// Adapted from check-word-mute.ts
|
||||
function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean {
|
||||
if (!note) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mutes || mutes.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!checkWordMute(note, $i, mutes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
|
45
packages/frontend/src/components/SkMutedNote.vue
Normal file
45
packages/frontend/src/components/SkMutedNote.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ mutedWords }}
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
muted: false | 'sensitiveMute' | string[];
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const mutedWords = computed(() => Array.isArray(props.muted)
|
||||
? props.muted.join(', ')
|
||||
: props.muted);
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
|
@ -88,13 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:isAnim="allowAnim"
|
||||
:isBlock="true"
|
||||
/>
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else-if="translation">
|
||||
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
|
||||
</div>
|
||||
</div>
|
||||
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
|
||||
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
|
||||
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
</div>
|
||||
|
@ -103,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
|
||||
|
@ -161,10 +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 ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
|
||||
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.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" @click.stop="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
|
@ -172,24 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</article>
|
||||
</div>
|
||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
|
@ -205,7 +185,7 @@ import * as mfm from '@transfem-org/sfm-js';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
|
||||
import type { Ref } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
@ -224,7 +204,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
@ -236,7 +216,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
|||
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js';
|
||||
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
|
@ -246,13 +226,17 @@ 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 { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { instance, isEnabledUrlPreview } from '@/instance.js';
|
||||
import { focusPrev, focusNext } from '@/utility/focus.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
|
||||
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
|
||||
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -272,8 +256,6 @@ const emit = defineEmits<{
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
|
@ -322,15 +304,14 @@ const galleryEl = useTemplateRef('galleryEl');
|
|||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
|
||||
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
|
||||
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
|
||||
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
||||
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
|
||||
|
@ -347,38 +328,13 @@ const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
|
||||
*/
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) return result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) return replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) return renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let renoting = false;
|
||||
|
||||
const keymap = {
|
||||
|
@ -403,6 +359,11 @@ const keymap = {
|
|||
if (!prefer.s.showClipButtonInNoteFooter) return;
|
||||
clip();
|
||||
},
|
||||
't': () => {
|
||||
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
|
||||
translate();
|
||||
}
|
||||
},
|
||||
'o': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
galleryEl.value?.openGallery();
|
||||
|
@ -825,6 +786,12 @@ async function clip(): Promise<void> {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
if (props.mock) return;
|
||||
|
||||
await translateNote(appearNote.value.id, translation, translating);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
|
@ -1233,13 +1200,6 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.urlPreview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
|
|
@ -109,13 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
class="_selectable"
|
||||
/>
|
||||
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else-if="translation">
|
||||
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
|
||||
</div>
|
||||
</div>
|
||||
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
|
||||
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
|
||||
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
|
@ -123,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
|
||||
</div>
|
||||
|
@ -177,10 +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 ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
|
||||
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.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="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
|
@ -235,11 +232,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -248,7 +241,7 @@ import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, useT
|
|||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
|
@ -265,7 +258,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import SkInstanceTicker from '@/components/SkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
@ -276,7 +269,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js';
|
|||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
|
||||
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
|
||||
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
|
@ -290,11 +283,15 @@ 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 { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { instance, isEnabledUrlPreview } from '@/instance.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
|
||||
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
|
||||
import { extractPreviewUrls } from '@/utility/extract-preview-urls';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -346,12 +343,12 @@ const isMyRenote = $i && ($i.id === note.value.userId);
|
|||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
|
||||
const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
|
||||
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
|
||||
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
|
||||
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
|
@ -364,6 +361,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
|||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
watch(() => props.expandAllCws, (expandAllCws) => {
|
||||
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
|
||||
});
|
||||
|
@ -382,7 +381,7 @@ let renoting = false;
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
const keymap = {
|
||||
|
@ -394,6 +393,11 @@ const keymap = {
|
|||
if (!prefer.s.showClipButtonInNoteFooter) return;
|
||||
clip();
|
||||
},
|
||||
't': () => {
|
||||
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
|
||||
translate();
|
||||
}
|
||||
},
|
||||
'o': () => galleryEl.value?.openGallery(),
|
||||
'v|enter': () => {
|
||||
if (appearNote.value.cw != null) {
|
||||
|
@ -772,6 +776,10 @@ async function clip(): Promise<void> {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
await translateNote(appearNote.value.id, translation, translating);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
|
@ -1102,13 +1110,6 @@ onUnmounted(() => {
|
|||
color: var(--MI_THEME-renote);
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.poll {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
|
|
@ -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 && $i?.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>
|
||||
|
@ -81,19 +87,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</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 { host } from '@@/js/config.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';
|
||||
|
@ -107,16 +110,18 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
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 } from '@/instance';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -140,12 +145,12 @@ const canRenote = computed(() => ['public', 'home'].includes(props.note.visibili
|
|||
const hideLine = computed(() => props.detail);
|
||||
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
|
||||
const translation = ref<any>(null);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
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>();
|
||||
|
@ -168,9 +173,11 @@ const isRenote = (
|
|||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
|
||||
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;
|
||||
|
@ -184,6 +191,8 @@ async function removeReply(id: Misskey.entities.Note['id']) {
|
|||
}
|
||||
}
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: appearNote,
|
||||
|
@ -392,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,
|
||||
|
|
48
packages/frontend/src/components/SkNoteTranslation.vue
Normal file
48
packages/frontend/src/components/SkNoteTranslation.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="translating || translation != null" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else-if="translation && translation.text != null">
|
||||
<b v-if="translation.sourceLang">{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" class="_selectable"/>
|
||||
</div>
|
||||
<div v-else>{{ i18n.ts.translationFailed }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { watch } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
translating?: boolean;
|
||||
translation?: Misskey.entities.NotesTranslateResponse | false | null;
|
||||
}>(), {
|
||||
translating: false,
|
||||
translation: null,
|
||||
});
|
||||
|
||||
if (_DEV_) {
|
||||
// Prop watch syntax: https://stackoverflow.com/a/59127059
|
||||
watch(
|
||||
[() => props.translation, () => props.translating],
|
||||
([translation, translating]) => console.debug('Translation status changed: ', { translation, translating }),
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
|
@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div>
|
||||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
|
||||
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
|
||||
</div>
|
||||
</header>
|
||||
<div :class="$style.noteContent">
|
||||
|
@ -42,18 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
|
||||
<Mfm v-if="appearNote.text" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
|
||||
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else>
|
||||
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
|
||||
</div>
|
||||
</div>
|
||||
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
||||
|
@ -92,12 +86,14 @@ import MkPoll from '@/components/MkPoll.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { dateTimeFormat } from '@/utility/intl-const.js';
|
||||
import { prefer } from '@/preferences';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
|
||||
import { getSelfNoteIds } from '@/utility/get-self-note-ids';
|
||||
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
|
||||
|
||||
const props = defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -146,14 +142,14 @@ const isRenote = (
|
|||
);
|
||||
|
||||
const el = shallowRef<HTMLElement>();
|
||||
let appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
|
||||
const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null;
|
||||
const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null;
|
||||
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
|
||||
const showContent = ref(false);
|
||||
const translation = ref(null);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const urls = appearNote.value.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null;
|
||||
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
|
||||
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
|
||||
</script>
|
||||
|
@ -259,13 +255,6 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
|
|||
color: var(--MI_THEME-renote);
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.poll {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #caption>{{ i18n.ts.wordMuteTestDescription }}</template>
|
||||
</MkTextarea>
|
||||
<div><MkButton :disabled="!testWords" @click="testWordMutes">{{ i18n.ts.wordMuteTestTest }}</MkButton></div>
|
||||
<div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults}}</div>
|
||||
<div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults }}</div>
|
||||
<div v-else-if="testMatches === ''">{{ i18n.ts.wordMuteTestNoMatch }}</div>
|
||||
<div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div>
|
||||
</div>
|
||||
|
@ -44,7 +44,7 @@ function testWordMutes() {
|
|||
try {
|
||||
const mutes = parseMutes(props.mutedWords);
|
||||
const matches = checkWordMute(testWords.value, null, mutes);
|
||||
testMatches.value = matches ? matches.flat(2).join(', ') : '';
|
||||
testMatches.value = matches ? matches.join(', ') : '';
|
||||
} catch {
|
||||
// Error is displayed by above function
|
||||
testMatches.value = null;
|
||||
|
|
|
@ -7,38 +7,49 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
|
||||
<FormSuspense :p="init">
|
||||
<div v-if="tab === 'overview'" class="_gaps_m">
|
||||
<div class="aeakzknw">
|
||||
<div v-if="tab === 'overview'" class="_gaps">
|
||||
<div v-if="user" class="aeakzknw">
|
||||
<MkAvatar class="avatar" :user="user" indicator link preview/>
|
||||
<div class="body">
|
||||
<span class="name"><MkUserName class="name" :user="user"/></span>
|
||||
<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
|
||||
<span class="sub">
|
||||
<span class="acct _monospace">@{{ acct(user) }}</span>
|
||||
<button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard('@' + acct(user))"><i class="ti ti-copy"></i></button>
|
||||
</span>
|
||||
<span class="sub">
|
||||
<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">Suspended</span>
|
||||
<span v-if="silenced" class="silenced">Silenced</span>
|
||||
<span v-if="moderator" class="moderator">Moderator</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>
|
||||
|
||||
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
|
||||
|
||||
<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1em;">
|
||||
<MkKeyValue :copy="user.id" oneline>
|
||||
<template #key>ID</template>
|
||||
<template #value><span class="_monospace">{{ user.id }}</span></template>
|
||||
</MkKeyValue>
|
||||
<!-- 要る?
|
||||
<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
|
||||
<template #key>IP (recent)</template>
|
||||
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
|
||||
</MkKeyValue>
|
||||
-->
|
||||
<template v-if="!isSystem">
|
||||
<MkFolder v-if="!isSystem">
|
||||
<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 v-if="user" :copy="user.id" oneline>
|
||||
<template #key>{{ i18n.ts.id }}</template>
|
||||
<template #value><span class="_monospace">{{ user.id }}</span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="user" :copy="'@' + acct(user)" oneline>
|
||||
<template #key>{{ i18n.ts.username }}</template>
|
||||
<template #value><span class="_monospace">@{{ acct(user) }}</span></template>
|
||||
</MkKeyValue>
|
||||
<!-- 要る?
|
||||
<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
|
||||
<template #key>IP (recent)</template>
|
||||
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
|
||||
</MkKeyValue>
|
||||
-->
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.createdAt }}</template>
|
||||
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
|
||||
|
@ -51,16 +62,64 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #key>{{ i18n.ts.email }}</template>
|
||||
<template #value><span class="_monospace">{{ info.email }}</span></template>
|
||||
</MkKeyValue>
|
||||
</template>
|
||||
</div>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.totalFollowers }}</template>
|
||||
<template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowers"></MkNumber></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.totalFollowing }}</template>
|
||||
<template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowing"></MkNumber></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.remoteFollowers }}</template>
|
||||
<template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowers"></MkNumber></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.remoteFollowing }}</template>
|
||||
<template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowing"></MkNumber></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.localFollowers }}</template>
|
||||
<template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowers"></MkNumber></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.localFollowing }}</template>
|
||||
<template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowing"></MkNumber></span></template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
|
||||
<MkFolder v-if="info">
|
||||
<template #icon><i class="ph-scroll ph-bold ph-lg"></i></template>
|
||||
<template #label>{{ i18n.ts._role.policies }}</template>
|
||||
<div class="_gaps">
|
||||
<div v-for="policy in Object.keys(info.policies)" :key="policy">
|
||||
{{ policy }} ... {{ info.policies[policy] }}
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="iAmAdmin && ips && ips.length > 0">
|
||||
<template #icon><i class="ph-network ph-bold ph-lg"></i></template>
|
||||
<template #label>{{ i18n.ts.ip }}</template>
|
||||
<MkInfo>{{ i18n.ts.ipTip }}</MkInfo>
|
||||
<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
|
||||
<span class="date">{{ record.createdAt }}</span>
|
||||
<span class="ip">{{ record.ip }}</span>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0">
|
||||
<template #icon><i class="ph-stamp ph-bold ph-lg"></i></template>
|
||||
<template #label>{{ i18n.ts.moderationNote }}</template>
|
||||
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
|
||||
</MkTextarea>
|
||||
<MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
|
||||
<template #label>{{ i18n.ts.moderationNote }}</template>
|
||||
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
|
||||
</MkTextarea>
|
||||
</MkFolder>
|
||||
|
||||
<FormSection v-if="user.host">
|
||||
<template #label>ActivityPub</template>
|
||||
<FormSection v-if="user?.host">
|
||||
<template #label>{{ i18n.ts.activityPub }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<div style="display: flex; flex-direction: column; gap: 1em;">
|
||||
|
@ -73,12 +132,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #value><MkTime mode="detail" :time="user.lastFetchedAt"/></template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
|
||||
<MkButton @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection v-if="!isSystem">
|
||||
<FormSection v-if="!isSystem && user && iAmModerator">
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
|
||||
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
|
||||
|
@ -90,58 +147,40 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
|
||||
</MkInput>
|
||||
|
||||
<div>
|
||||
<MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
|
||||
<div :class="$style.buttonStrip">
|
||||
<MkButton v-if="user.host != null" inline @click="updateRemoteUser"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
|
||||
<MkButton v-if="user.host == null" inline accent @click="resetPassword"><i class="ph-password ph-bold ph-lg"></i> {{ i18n.ts.resetPassword }}</MkButton>
|
||||
<MkButton inline accent @click="unsetUserAvatar"><i class="ph-camera-slash ph-bold ph-lg"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
|
||||
<MkButton inline accent @click="unsetUserBanner"><i class="ph-image-broken ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
|
||||
<MkButton inline danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
|
||||
<MkButton v-if="iAmAdmin" inline danger @click="deleteAccount"><i class="ph-skull ph-bold ph-lg"></i> {{ i18n.ts.deleteAccount }}</MkButton>
|
||||
</div>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ph-scroll ph-bold ph-lg"></i></template>
|
||||
<template #label>{{ i18n.ts._role.policies }}</template>
|
||||
<div class="_gaps">
|
||||
<div v-for="policy in Object.keys(info.policies)" :key="policy">
|
||||
{{ policy }} ... {{ info.policies[policy] }}
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-password"></i></template>
|
||||
<template #label>IP</template>
|
||||
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
|
||||
<!-- TODO translate -->
|
||||
<MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
|
||||
<template v-if="iAmAdmin && ips">
|
||||
<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
|
||||
<span class="date">{{ record.createdAt }}</span>
|
||||
<span class="ip">{{ record.ip }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</MkFolder>
|
||||
|
||||
<div>
|
||||
<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
|
||||
<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
|
||||
<MkButton v-if="iAmModerator" inline danger @click="deleteAllFiles"><i class="ph-cloud ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
|
||||
</div>
|
||||
<MkButton v-if="$i.isAdmin && !isSystem" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'roles'" class="_gaps">
|
||||
<MkButton v-if="user.host == null" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
|
||||
<MkButton primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
|
||||
|
||||
<div v-for="role in info.roles" :key="role.id">
|
||||
<div :class="$style.roleItemMain">
|
||||
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
|
||||
<button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
|
||||
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
|
||||
<button class="_button" @click="toggleRoleItem(role)">
|
||||
<i v-if="!expandedRoles.includes(role.id)" class="ti ti-chevron-down"></i>
|
||||
<i v-if="expandedRoles.includes(role.id)" class="ti ti-chevron-left"></i>
|
||||
</button>
|
||||
<button v-if="role.target === 'manual' || info.roleAssigns.some(a => a.roleId === role.id)" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
|
||||
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
|
||||
</div>
|
||||
<div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub">
|
||||
<div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
|
||||
<div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
|
||||
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
|
||||
<template v-if="info.roleAssigns.some(a => a.roleId === role.id)">
|
||||
<div>{{ i18n.ts.roleAssigned }}: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
|
||||
<div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">{{ i18n.ts.rolePeriod }}: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
|
||||
<div v-else>{{ i18n.ts.rolePeriod }}: {{ i18n.ts.indefinitely }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>{{ i18n.ts.roleAssigned }}: {{ i18n.ts.roleAutomatic }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -231,6 +270,8 @@ import { iAmAdmin, $i, iAmModerator } from '@/i.js';
|
|||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
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';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
userId: string;
|
||||
|
@ -740,4 +781,12 @@ definePage(() => ({
|
|||
border-radius: var(--MI-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttonStrip {
|
||||
margin: calc(var(--MI-margin) / 2 * -1);
|
||||
|
||||
>* {
|
||||
margin: calc(var(--MI-margin) / 2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,6 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
|
||||
<FormSuspense :p="init">
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="translationTimeout" type="number" manualSave @update:modelValue="saveTranslationTimeout">
|
||||
<template #label>{{ i18n.ts.translationTimeoutLabel }}</template>
|
||||
<template #caption>{{ i18n.ts.translationTimeoutCaption }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>DeepL Translation</template>
|
||||
|
||||
|
@ -69,6 +74,7 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePage } from '@/page.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
const translationTimeout = ref(0);
|
||||
const deeplAuthKey = ref<string | null>('');
|
||||
const deeplIsPro = ref<boolean>(false);
|
||||
const deeplFreeMode = ref<boolean>(false);
|
||||
|
@ -78,6 +84,7 @@ const libreTranslateKey = ref<string | null>('');
|
|||
|
||||
async function init() {
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
translationTimeout.value = meta.translationTimeout;
|
||||
deeplAuthKey.value = meta.deeplAuthKey;
|
||||
deeplIsPro.value = meta.deeplIsPro;
|
||||
deeplFreeMode.value = meta.deeplFreeMode;
|
||||
|
@ -86,6 +93,13 @@ async function init() {
|
|||
libreTranslateKey.value = meta.libreTranslateKey;
|
||||
}
|
||||
|
||||
async function saveTranslationTimeout() {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
translationTimeout: translationTimeout.value,
|
||||
});
|
||||
await os.promiseDialog(fetchInstance(true));
|
||||
}
|
||||
|
||||
function save_deepl() {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
deeplAuthKey: deeplAuthKey.value,
|
||||
|
@ -93,7 +107,7 @@ function save_deepl() {
|
|||
deeplFreeMode: deeplFreeMode.value,
|
||||
deeplFreeInstance: deeplFreeInstance.value,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
os.promiseDialog(fetchInstance(true));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -102,7 +116,7 @@ function save_libre() {
|
|||
libreTranslateURL: libreTranslateURL.value,
|
||||
libreTranslateKey: libreTranslateKey.value,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
os.promiseDialog(fetchInstance(true));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -797,6 +797,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
|
||||
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.canTrend.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.canTrend.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canTrend)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.canTrend.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkSwitch v-model="role.policies.canTrend.value" :disabled="role.policies.canTrend.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
<MkRange v-model="role.policies.canTrend.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSlot>
|
||||
</div>
|
||||
|
|
|
@ -300,6 +300,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
|
||||
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
|
||||
<template #suffix>{{ policies.canTrend ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
<MkSwitch v-model="policies.canTrend">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
|
||||
|
|
|
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
/>
|
||||
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
|
||||
</MkFukidashi>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/>
|
||||
<div :class="$style.footer">
|
||||
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
|
||||
<MkTime :class="$style.time" :time="message.createdAt"/>
|
||||
|
|
|
@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:moveClass="prefer.s.animation ? $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>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -46,7 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template v-if="tag == null">
|
||||
<MkFoldableSection class="_margin">
|
||||
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
|
||||
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.tsx.popularUsersLocal({ name: instance.name ?? host }) }}</template>
|
||||
<MkUserList :pagination="popularUsersLocalF"/>
|
||||
</MkFoldableSection>
|
||||
<MkFoldableSection class="_margin">
|
||||
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsersGlobal }}</template>
|
||||
<MkUserList :pagination="popularUsersF"/>
|
||||
</MkFoldableSection>
|
||||
<MkFoldableSection class="_margin">
|
||||
|
@ -65,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { watch, ref, useTemplateRef, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config';
|
||||
import MkUserList from '@/components/MkUserList.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
|
@ -73,7 +78,7 @@ import { instance } from '@/instance.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string;
|
||||
tag?: string | undefined;
|
||||
}>();
|
||||
|
||||
const origin = ref('local');
|
||||
|
@ -86,43 +91,48 @@ watch(() => props.tag, () => {
|
|||
});
|
||||
|
||||
const tagUsers = computed(() => ({
|
||||
endpoint: 'hashtags/users' as const,
|
||||
endpoint: 'hashtags/users',
|
||||
limit: 30,
|
||||
params: {
|
||||
tag: props.tag,
|
||||
origin: 'combined',
|
||||
sort: '+follower',
|
||||
},
|
||||
}));
|
||||
} as const));
|
||||
|
||||
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
|
||||
const pinnedUsers = { endpoint: 'pinned-users', limit: 10, noPaging: true } as const;
|
||||
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'local',
|
||||
sort: '+follower',
|
||||
} };
|
||||
} } as const;
|
||||
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'local',
|
||||
sort: '+updatedAt',
|
||||
} };
|
||||
} } as const;
|
||||
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'local',
|
||||
state: 'alive',
|
||||
sort: '+createdAt',
|
||||
} };
|
||||
} } as const;
|
||||
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'remote',
|
||||
sort: '+follower',
|
||||
} };
|
||||
} } as const;
|
||||
const popularUsersLocalF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'remote',
|
||||
sort: '+localFollower',
|
||||
} } as const;
|
||||
const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'combined',
|
||||
sort: '+updatedAt',
|
||||
} };
|
||||
} } as const;
|
||||
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'combined',
|
||||
sort: '+createdAt',
|
||||
} };
|
||||
} } as const;
|
||||
|
||||
misskeyApi('hashtags/list', {
|
||||
sort: '+attachedLocalUsers',
|
||||
|
|
|
@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import DynamicNoteDetailed from '@/components/DynamicNoteDetailed.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
|
@ -151,7 +151,7 @@ function fetchNote() {
|
|||
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
|
||||
openOnRemote: {
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${props.noteId}`,
|
||||
url: `${config.url}/notes/${props.noteId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_gaps_m">
|
||||
<MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo>
|
||||
|
||||
<MkInfo warn>{{ i18n.ts.wordMuteWarning }}</MkInfo>
|
||||
|
||||
<SearchMarker
|
||||
:label="i18n.ts.showMutedWord"
|
||||
:keywords="['show']"
|
||||
|
@ -44,6 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div class="_gaps_m">
|
||||
<MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
|
||||
|
||||
<MkInfo warn>{{ i18n.ts.wordMuteWarning }}</MkInfo>
|
||||
|
||||
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
|
@ -196,8 +196,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>
|
||||
|
@ -271,6 +271,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['footer', 'action', 'translation', 'show']">
|
||||
<MkPreferenceContainer k="showTranslationButtonInNoteFooter">
|
||||
<MkSwitch v-model="showTranslationButtonInNoteFooter">
|
||||
<template #label><SearchLabel>{{ i18n.ts.showTranslationButtonInNoteFooter }}</SearchLabel></template>
|
||||
</MkSwitch>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['reaction', 'count', 'show']">
|
||||
<MkPreferenceContainer k="showReactionsCount">
|
||||
<MkSwitch v-model="showReactionsCount">
|
||||
|
@ -428,9 +436,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>
|
||||
|
@ -851,24 +859,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>
|
||||
|
@ -900,8 +912,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">
|
||||
|
@ -973,6 +985,7 @@ const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior');
|
|||
const hemisphere = prefer.model('hemisphere');
|
||||
const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover');
|
||||
const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter');
|
||||
const showTranslationButtonInNoteFooter = prefer.model('showTranslationButtonInNoteFooter');
|
||||
const collapseRenotes = prefer.model('collapseRenotes');
|
||||
const advancedMfm = prefer.model('advancedMfm');
|
||||
const showReactionsCount = prefer.model('showReactionsCount');
|
||||
|
@ -1109,6 +1122,7 @@ watch([
|
|||
makeEveryTextElementsSelectable,
|
||||
enableHorizontalSwipe,
|
||||
enablePullToRefresh,
|
||||
noteDesign,
|
||||
], async () => {
|
||||
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
|
||||
});
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue