Merge branch 'develop' into upstream/2025.5.0

This commit is contained in:
dakkar 2025-05-30 11:13:37 +01:00
commit 46bb75d274
116 changed files with 2636 additions and 973 deletions

View file

@ -297,6 +297,10 @@ proxyBypassHosts:
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 #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 # Media Proxy
#mediaProxy: https://example.com/proxy #mediaProxy: https://example.com/proxy
@ -321,9 +325,24 @@ attachLdSignatureForRelays: true
# For security reasons, uploading attachments from the intranet is prohibited, # For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined". # 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)). # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [ # Some example configurations:
# '127.0.0.1/32' #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'] #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']

View file

@ -260,6 +260,10 @@ proxyBypassHosts:
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 #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 # Media Proxy
#mediaProxy: https://example.com/proxy #mediaProxy: https://example.com/proxy
@ -269,9 +273,27 @@ proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true
allowedPrivateNetworks: [ # For security reasons, uploading attachments from the intranet is prohibited,
'127.0.0.1/32' # 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) # 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. # This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.

View file

@ -351,6 +351,10 @@ proxyBypassHosts:
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 #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 # Media Proxy
# Reference Implementation: https://github.com/misskey-dev/media-proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy
# * Deliver a common cache between instances # * Deliver a common cache between instances
@ -378,9 +382,24 @@ attachLdSignatureForRelays: true
# For security reasons, uploading attachments from the intranet is prohibited, # For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined". # 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)). # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [ # Some example configurations:
# '127.0.0.1/32' #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'] #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']

View file

@ -354,6 +354,10 @@ proxyBypassHosts:
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 #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 # Media Proxy
# Reference Implementation: https://github.com/misskey-dev/media-proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy
# * Deliver a common cache between instances # * Deliver a common cache between instances
@ -381,9 +385,24 @@ attachLdSignatureForRelays: true
# For security reasons, uploading attachments from the intranet is prohibited, # For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined". # 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)). # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [ # Some example configurations:
# '127.0.0.1/32' #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'] #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']

View file

@ -199,6 +199,10 @@ proxyBypassHosts:
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 #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 # Media Proxy
#mediaProxy: https://example.com/proxy #mediaProxy: https://example.com/proxy

86
locales/index.d.ts vendored
View file

@ -2451,7 +2451,7 @@ export interface Locale extends ILocale {
*/ */
"disablePagesScript": string; "disablePagesScript": string;
/** /**
* * Refresh remote data
*/ */
"updateRemoteUser": string; "updateRemoteUser": string;
/** /**
@ -7631,6 +7631,10 @@ export interface Locale extends ILocale {
* Maximum number of scheduled notes * Maximum number of scheduled notes
*/ */
"scheduleNoteMax": string; "scheduleNoteMax": string;
/**
* Can appear in trending notes / users
*/
"canTrend": string;
}; };
"_condition": { "_condition": {
/** /**
@ -12961,6 +12965,14 @@ export interface Locale extends ILocale {
* Fetch linked note * Fetch linked note
*/ */
"fetchLinkedNote": string; "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": { "_processErrors": {
/** /**
* Unable to process quote. This post may be missing context. * 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. * Text does not match any patterns.
*/ */
"wordMuteTestNoMatch": string; "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 * 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. * Note: the bubble timeline is hidden by default, and must be enabled via roles.
*/ */
"bubbleTimelineMustBeEnabled": string; "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: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -1,6 +1,6 @@
{ {
"name": "sharkey", "name": "sharkey",
"version": "2025.5.1-rc", "version": "2025.5.2-dev",
"codename": "shonk", "codename": "shonk",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -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"`);
}
}

View file

@ -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"`);
}
}

View file

@ -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`);
}
}

View file

@ -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`);
}
}

View file

@ -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`);
}
}

View file

@ -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`);
}
}

View file

@ -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`);
}
}

View file

@ -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`);
}
}

View file

@ -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"`);
}
}

View file

@ -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`);
}
}

View file

@ -8,9 +8,11 @@ import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { globSync } from 'glob'; import { globSync } from 'glob';
import ipaddr from 'ipaddr.js';
import type * as Sentry from '@sentry/node'; import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue'; import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis'; import type { RedisOptions } from 'ioredis';
import type { IPv4, IPv6 } from 'ipaddr.js';
type RedisOptionsSource = Partial<RedisOptions> & { type RedisOptionsSource = Partial<RedisOptions> & {
host?: string; host?: string;
@ -82,7 +84,7 @@ type Source = {
proxySmtp?: string; proxySmtp?: string;
proxyBypassHosts?: string[]; proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[]; allowedPrivateNetworks?: PrivateNetworkSource[];
disallowExternalApRedirect?: boolean; disallowExternalApRedirect?: boolean;
maxFileSize?: number; maxFileSize?: number;
@ -109,6 +111,7 @@ type Source = {
deliverJobMaxAttempts?: number; deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number; inboxJobMaxAttempts?: number;
mediaDirectory?: string;
mediaProxy?: string; mediaProxy?: string;
proxyRemoteFiles?: boolean; proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string; 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 = { export type Config = {
url: string; url: string;
port: number; port: number;
@ -190,7 +247,7 @@ export type Config = {
proxy: string | undefined; proxy: string | undefined;
proxySmtp: string | undefined; proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined; proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined; allowedPrivateNetworks: PrivateNetwork[] | undefined;
disallowExternalApRedirect: boolean; disallowExternalApRedirect: boolean;
maxFileSize: number; maxFileSize: number;
maxNoteLength: number; maxNoteLength: number;
@ -241,6 +298,7 @@ export type Config = {
frontendManifestExists: boolean; frontendManifestExists: boolean;
frontendEmbedEntry: string; frontendEmbedEntry: string;
frontendEmbedManifestExists: boolean; frontendEmbedManifestExists: boolean;
mediaDirectory: string;
mediaProxy: string; mediaProxy: string;
externalMediaProxyEnabled: boolean; externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null; videoThumbnailGenerator: string | null;
@ -290,7 +348,7 @@ const _dirname = dirname(_filename);
/** /**
* Path of configuration directory * Path of configuration directory
*/ */
const dir = `${_dirname}/../../../.config`; const dir = process.env.MISSKEY_CONFIG_DIR ?? `${_dirname}/../../../.config`;
/** /**
* Path of configuration file * Path of configuration file
@ -382,7 +440,7 @@ export function loadConfig(): Config {
proxy: config.proxy, proxy: config.proxy,
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts, proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks, allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks),
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false, disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
maxFileSize: config.maxFileSize ?? 262144000, maxFileSize: config.maxFileSize ?? 262144000,
maxNoteLength: config.maxNoteLength ?? 3000, maxNoteLength: config.maxNoteLength ?? 3000,
@ -407,6 +465,7 @@ export function loadConfig(): Config {
signToActivityPubGet: config.signToActivityPubGet ?? true, signToActivityPubGet: config.signToActivityPubGet ?? true,
attachLdSignatureForRelays: config.attachLdSignatureForRelays ?? true, attachLdSignatureForRelays: config.attachLdSignatureForRelays ?? true,
checkActivityPubGetSignature: config.checkActivityPubGetSignature, checkActivityPubGetSignature: config.checkActivityPubGetSignature,
mediaDirectory: config.mediaDirectory ?? resolve(_dirname, '../../../files'),
mediaProxy: externalMediaProxy ?? internalMediaProxy, mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
videoThumbnailGenerator: config.videoThumbnailGenerator ? videoThumbnailGenerator: config.videoThumbnailGenerator ?
@ -575,14 +634,14 @@ function applyEnvOverrides(config: Source) {
['host', 'port', 'username', 'pass', 'db', 'prefix'], ['host', 'port', 'username', 'pass', 'db', 'prefix'],
]); ]);
_apply_top(['fulltextSearch', 'provider']); _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([['sentryForFrontend', 'sentryForBackend'], 'options', ['dsn', 'profileSampleRate', 'serverName', 'includeLocalVariables', 'proxy', 'keepAlive', 'caCerts']]);
_apply_top(['sentryForBackend', 'enableNodeProfiling']); _apply_top(['sentryForBackend', 'enableNodeProfiling']);
_apply_top(['sentryForFrontend', 'vueIntegration', ['attachProps', 'attachErrorHandler']]); _apply_top(['sentryForFrontend', 'vueIntegration', ['attachProps', 'attachErrorHandler']]);
_apply_top(['sentryForFrontend', 'vueIntegration', 'tracingOptions', 'timeout']); _apply_top(['sentryForFrontend', 'vueIntegration', 'tracingOptions', 'timeout']);
_apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']); _apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']);
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); _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([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]);

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { IsNull } from 'typeorm'; 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 { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -22,6 +22,17 @@ export interface FollowStats {
remoteFollowers: number; remoteFollowers: number;
} }
export interface CachedTranslation {
sourceLang: string | undefined;
text: string | undefined;
}
interface CachedTranslationEntity {
l?: string;
t?: string;
u?: number;
}
@Injectable() @Injectable()
export class CacheService implements OnApplicationShutdown { export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<MiUser>; public userByIdCache: MemoryKVCache<MiUser>;
@ -35,6 +46,7 @@ export class CacheService implements OnApplicationShutdown {
public renoteMutingsCache: RedisKVCache<Set<string>>; public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>; public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
private readonly translationsCache: RedisKVCache<CachedTranslationEntity>;
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redis)
@ -124,6 +136,11 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => JSON.parse(value), 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で行っている // NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
this.redisForSub.on('message', this.onMessage); 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 @bindThis
public dispose(): void { public dispose(): void {
this.redisForSub.off('message', this.onMessage); this.redisForSub.off('message', this.onMessage);

View file

@ -63,8 +63,6 @@ export class DeleteAccountService {
// 知り得る全SharedInboxにDelete配信 // 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({ const followings = await this.followingsRepository.find({
where: [ where: [
{ followerSharedInbox: Not(IsNull()) }, { followerSharedInbox: Not(IsNull()) },
@ -73,22 +71,17 @@ export class DeleteAccountService {
select: ['followerSharedInbox', 'followeeSharedInbox'], 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) { await this.queueService.deliverMany(user, content, queue);
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) { await this.queueService.createDeleteAccountJob(user, {
this.queueService.deliver(user, content, inbox, true);
}
this.queueService.createDeleteAccountJob(user, {
soft: false, soft: false,
}); });
} else { } else {
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
this.queueService.createDeleteAccountJob(user, { await this.queueService.createDeleteAccountJob(user, {
soft: true, soft: true,
}); });
} }

View file

@ -8,6 +8,7 @@ import * as Redis from 'ioredis';
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js'; import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
export const GALLERY_POSTS_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( constructor(
@Inject(DI.redis) @Inject(DI.redis)
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
private readonly roleService: RoleService,
) { ) {
} }
@ -31,7 +34,14 @@ export class FeaturedService {
} }
@bindThis @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 currentWindow = this.getCurrentWindow(windowRange);
const redisTransaction = this.redisClient.multi(); const redisTransaction = this.redisClient.multi();
redisTransaction.zincrby( redisTransaction.zincrby(
@ -89,28 +99,28 @@ export class FeaturedService {
} }
@bindThis @bindThis
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> { public updateGlobalNotesRanking(note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
} }
@bindThis @bindThis
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> { public updateGalleryPostsRanking(galleryPost: Pick<MiGalleryPost, 'id' | 'userId'>, score = 1): Promise<void> {
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score); return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId);
} }
@bindThis @bindThis
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> { public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
} }
@bindThis @bindThis
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> { public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick<MiNote, 'id'>, score = 1): Promise<void> {
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId);
} }
@bindThis @bindThis
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> { 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 @bindThis

View file

@ -12,7 +12,7 @@ import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; 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 { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.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 { ApUtilityService } from './activitypub/ApUtilityService.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
import type { Socket } from 'node:net';
export type HttpRequestSendOptions = { export type HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: boolean; throwErrorWhenResponseNotOk: boolean;
validators?: ((res: Response) => void)[]; 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' { declare module 'node:http' {
interface Agent { interface Agent {
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket; 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 { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback) const socket = super.createConnection(options, callback)
.on('connect', () => { .on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) { validateSocketConnect(this.config.allowedPrivateNetworks, socket);
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
} }
}); });
return 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 { 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 { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback) const socket = super.createConnection(options, callback)
.on('connect', () => { .on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) { validateSocketConnect(this.config.allowedPrivateNetworks, socket);
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
} }
}); });
return 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() @Injectable()
@ -250,6 +236,8 @@ export class HttpRequestService {
@bindThis @bindThis
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObjectWithId> { public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObjectWithId> {
this.apUtilityService.assertApUrl(url);
const res = await this.send(url, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: { headers: {

View file

@ -6,18 +6,11 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises'; import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises';
import * as Path from 'node:path'; import * as Path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const path = Path.resolve(_dirname, '../../../../files');
@Injectable() @Injectable()
export class InternalStorageService { export class InternalStorageService {
constructor( constructor(
@ -25,12 +18,12 @@ export class InternalStorageService {
private config: Config, private config: Config,
) { ) {
// No one should erase the working directory *while the server is running*. // 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 @bindThis
public resolvePath(key: string) { public resolvePath(key: string) {
return Path.resolve(path, key); return Path.resolve(this.config.mediaDirectory, key);
} }
@bindThis @bindThis

View file

@ -592,6 +592,8 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!this.isRenote(note) || this.isQuote(note)) { if (!this.isRenote(note) || this.isQuote(note)) {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); this.incNotesCountOfUser(user);
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
} }
this.pushToTl(note, user); 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) { 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) { if (data.poll && data.poll.expiresAt) {
@ -814,8 +816,8 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private incRenoteCount(renote: MiNote) { private async incRenoteCount(renote: MiNote, user: MiUser) {
this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
renoteCount: () => '"renoteCount" + 1', renoteCount: () => '"renoteCount" + 1',
}) })
@ -823,15 +825,18 @@ export class NoteCreateService implements OnApplicationShutdown {
.execute(); .execute();
// 30%の確率、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、3日以内に投稿されたートの場合ハイライト用ランキング更新
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { if (user.isExplorable && Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
if (renote.channelId != null) { const policies = await this.roleService.getUserPolicies(user);
if (renote.replyId == null) { if (policies.canTrend) {
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5); if (renote.channelId != null) {
} if (renote.replyId == null) {
} else { this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { }
this.featuredService.updateGlobalNotesRanking(renote.id, 5); } else {
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5); if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
this.featuredService.updateGlobalNotesRanking(renote, 5);
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
}
} }
} }
} }

View file

@ -124,9 +124,11 @@ export class NoteDeleteService {
this.perUserNotesChart.update(user, note, false); this.perUserNotesChart.update(user, note, false);
} }
if (note.renoteId && note.text || !note.renoteId) { if (!isRenote(note) || isQuote(note)) {
// Decrement notes count (user) // Decrement notes count (user)
this.decNotesCountOfUser(user); this.decNotesCountOfUser(user);
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
} }
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
@ -165,8 +167,11 @@ export class NoteDeleteService {
}); });
} }
if (note.uri) { const deletedUris = [note, ...cascadingNotes]
this.apLogService.deleteObjectLogs(note.uri) .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}'`)); .catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
} }
} }

View file

@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown {
} }
} }
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
// ハッシュタグ更新 // ハッシュタグ更新
this.pushToTl(note, user); this.pushToTl(note, user);

View file

@ -30,6 +30,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
import { CacheService } from '@/core/CacheService.js';
const FALLBACK = '\u2764'; const FALLBACK = '\u2764';
@ -102,6 +103,7 @@ export class ReactionService {
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
private notificationService: NotificationService, private notificationService: NotificationService,
private perUserReactionsChart: PerUserReactionsChart, private perUserReactionsChart: PerUserReactionsChart,
private readonly cacheService: CacheService,
) { ) {
} }
@ -212,20 +214,28 @@ export class ReactionService {
.execute(); .execute();
} }
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
// 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新
if ( if (
Math.random() < 0.3 && Math.random() < 0.3 &&
note.userId !== user.id && note.userId !== user.id &&
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3 (Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
) { ) {
if (note.channelId != null) { const author = await this.cacheService.findUserById(note.userId);
if (note.replyId == null) { if (author.isExplorable) {
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1); const policies = await this.roleService.getUserPolicies(author);
} if (policies.canTrend) {
} else { if (note.channelId != null) {
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { if (note.replyId == null) {
this.featuredService.updateGlobalNotesRanking(note.id, 1); this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1);
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 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 @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 // if already unreacted
const exist = await this.noteReactionsRepository.findOneBy({ exist ??= await this.noteReactionsRepository.findOneBy({
noteId: note.id, noteId: note.id,
userId: user.id, userId: user.id,
}); });
@ -330,6 +340,8 @@ export class ReactionService {
.execute(); .execute();
} }
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
this.globalEventService.publishNoteStream(note.id, 'unreacted', { this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction, reaction: this.decodeReaction(exist.reaction).reaction,
userId: user.id, userId: user.id,

View file

@ -69,6 +69,7 @@ export type RolePolicies = {
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
chatAvailability: 'available' | 'readonly' | 'unavailable'; chatAvailability: 'available' | 'readonly' | 'unavailable';
canTrend: boolean;
}; };
export const DEFAULT_POLICIES: RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = {
@ -108,6 +109,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportMuting: true, canImportMuting: true,
canImportUserLists: true, canImportUserLists: true,
chatAvailability: 'available', chatAvailability: 'available',
canTrend: true,
}; };
@Injectable() @Injectable()
@ -149,6 +151,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
) { ) {
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
// TODO additional cache for final calculation?
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }
@ -358,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async getUserAssigns(userId: MiUser['id']) { public async getUserAssigns(userOrId: MiUser | MiUser['id']) {
const now = Date.now(); const now = Date.now();
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外 // 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
@ -367,12 +371,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async getUserRoles(userId: MiUser['id']) { public async getUserRoles(userOrId: MiUser | MiUser['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); 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 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 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)); const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedRoles, ...matchedCondRoles]; return [...assignedRoles, ...matchedCondRoles];
} }
@ -381,8 +386,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
* *
*/ */
@bindThis @bindThis
public async getUserBadgeRoles(userId: MiUser['id']) { public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) {
const now = Date.now(); const now = Date.now();
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外 // 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); 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 assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
if (badgeCondRoles.length > 0) { 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)); const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
} else { } else {
@ -401,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @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 }; 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]) { function calc<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) {
if (roles.length === 0) return basePolicies[name]; 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)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
chatAvailability: calc('chatAvailability', aggregateChatAvailability), chatAvailability: calc('chatAvailability', aggregateChatAvailability),
canTrend: calc('canTrend', vs => vs.some(v => v === true)),
}; };
} }

View file

@ -155,6 +155,8 @@ export class ApRequestService {
@bindThis @bindThis
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> { 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 body = typeof object === 'string' ? object : JSON.stringify(object);
const keypair = await this.userKeypairService.getUserKeypair(user.id); const keypair = await this.userKeypairService.getUserKeypair(user.id);
@ -186,6 +188,8 @@ export class ApRequestService {
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObjectWithId> { public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObjectWithId> {
this.apUtilityService.assertApUrl(url);
const _followAlternate = followAlternate ?? true; const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id); const keypair = await this.userKeypairService.getUserKeypair(user.id);

View file

@ -77,16 +77,48 @@ export class ApUtilityService {
return acceptableUrls[0]?.url ?? null; 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. * Checks if the URL contains HTTPS.
* Additionally, allows HTTP in non-production environments. * Additionally, allows HTTP in non-production environments.
* Based on check-https.ts. * Based on check-https.ts.
*/ */
private checkHttps(url: string): boolean { private checkHttps(url: string | URL): boolean {
const isNonProd = this.envService.env.NODE_ENV !== 'production'; const isNonProd = this.envService.env.NODE_ENV !== 'production';
// noinspection HttpUrlsUsage try {
return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); const proto = new URL(url).protocol;
return proto === 'https:' || (proto === 'http:' && isNonProd);
} catch {
// Invalid URLs don't "count" as HTTPS
return false;
}
} }
} }

View file

@ -95,6 +95,7 @@ export class ApNoteService {
actor?: MiRemoteUser, actor?: MiRemoteUser,
user?: MiRemoteUser, user?: MiRemoteUser,
): Error | null { ): Error | null {
this.apUtilityService.assertApUrl(uri);
const expectHost = this.utilityService.extractDbHost(uri); const expectHost = this.utilityService.extractDbHost(uri);
const apType = getApType(object); const apType = getApType(object);

View file

@ -153,6 +153,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
*/ */
@bindThis @bindThis
private validateActor(x: IObject, uri: string): IActor { private validateActor(x: IObject, uri: string): IActor {
this.apUtilityService.assertApUrl(uri);
const expectHost = this.utilityService.punyHostPSLDomain(uri); const expectHost = this.utilityService.punyHostPSLDomain(uri);
if (!isActor(x)) { if (!isActor(x)) {
@ -167,6 +168,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`); throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`);
} }
this.apUtilityService.assertApUrl(x.inbox);
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox); const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
if (inboxHost !== expectHost) { if (inboxHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`); 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); const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (sharedInboxObject != null) { if (sharedInboxObject != null) {
const sharedInbox = getApId(sharedInboxObject); const sharedInbox = getApId(sharedInboxObject);
this.apUtilityService.assertApUrl(sharedInbox);
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) { if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`); throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`);
} }
@ -185,6 +188,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
if (xCollection != null) { if (xCollection != null) {
const collectionUri = getApId(xCollection); const collectionUri = getApId(xCollection);
if (typeof collectionUri === 'string' && collectionUri.length > 0) { if (typeof collectionUri === 'string' && collectionUri.length > 0) {
this.apUtilityService.assertApUrl(collectionUri);
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) { if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`); throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`);
} }

View file

@ -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])[]); 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() @Injectable()
export class NotificationEntityService implements OnModuleInit { export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService; private userEntityService: UserEntityService;
@ -75,9 +82,9 @@ export class NotificationEntityService implements OnModuleInit {
const noteIfNeed = needsNote ? ( const noteIfNeed = needsNote ? (
hint?.packedNotes != null hint?.packedNotes != null
? hint.packedNotes.get(notification.noteId) ? hint.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.noteId, { id: meId }, { : undefOnMissing(this.noteEntityService.pack(notification.noteId, { id: meId }, {
detail: true, detail: true,
}) }))
) : undefined; ) : undefined;
// if the note has been deleted, don't show this notification // if the note has been deleted, don't show this notification
if (needsNote && !noteIfNeed) return null; if (needsNote && !noteIfNeed) return null;
@ -86,7 +93,7 @@ export class NotificationEntityService implements OnModuleInit {
const userIfNeed = needsUser ? ( const userIfNeed = needsUser ? (
hint?.packedUsers != null hint?.packedUsers != null
? hint.packedUsers.get(notification.notifierId) ? hint.packedUsers.get(notification.notifierId)
: this.userEntityService.pack(notification.notifierId, { id: meId }) : undefOnMissing(this.userEntityService.pack(notification.notifierId, { id: meId }))
) : undefined; ) : undefined;
// if the user has been deleted, don't show this notification // if the user has been deleted, don't show this notification
if (needsUser && !userIfNeed) return null; 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 reactions = (await Promise.all(notification.reactions.map(async reaction => {
const user = hint?.packedUsers != null const user = hint?.packedUsers != null
? hint.packedUsers.get(reaction.userId)! ? hint.packedUsers.get(reaction.userId)!
: await this.userEntityService.pack(reaction.userId, { id: meId }); : await undefOnMissing(this.userEntityService.pack(reaction.userId, { id: meId }));
return { return {
user, user,
reaction: reaction.reaction, reaction: reaction.reaction,
@ -121,7 +128,7 @@ export class NotificationEntityService implements OnModuleInit {
return packedUser; return packedUser;
} }
return this.userEntityService.pack(userId, { id: meId }); return undefOnMissing(this.userEntityService.pack(userId, { id: meId }));
}))).filter(x => x != null); }))).filter(x => x != null);
// if all users have been deleted, don't show this notification // if all users have been deleted, don't show this notification
if (users.length === 0) { if (users.length === 0) {
@ -140,10 +147,7 @@ export class NotificationEntityService implements OnModuleInit {
const needsRole = notification.type === 'roleAssigned'; const needsRole = notification.type === 'roleAssigned';
const role = needsRole const role = needsRole
? await this.roleEntityService.pack(notification.roleId).catch(err => { ? await undefOnMissing(this.roleEntityService.pack(notification.roleId))
if (err instanceof EntityNotFoundError) return undefined;
throw err;
})
: undefined; : undefined;
// if the role has been deleted, don't show this notification // if the role has been deleted, don't show this notification
if (needsRole && !role) { if (needsRole && !role) {

View file

@ -19,16 +19,16 @@ export class RedisKVCache<T> {
opts: { opts: {
lifetime: RedisKVCache<T>['lifetime']; lifetime: RedisKVCache<T>['lifetime'];
memoryCacheLifetime: number; memoryCacheLifetime: number;
fetcher: RedisKVCache<T>['fetcher']; fetcher?: RedisKVCache<T>['fetcher'];
toRedisConverter: RedisKVCache<T>['toRedisConverter']; toRedisConverter?: RedisKVCache<T>['toRedisConverter'];
fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; fromRedisConverter?: RedisKVCache<T>['fromRedisConverter'];
}, },
) { ) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); });
this.toRedisConverter = opts.toRedisConverter; this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value));
this.fromRedisConverter = opts.fromRedisConverter; this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value));
} }
@bindThis @bindThis

View file

@ -22,7 +22,7 @@ export class MiAbuseReportNotificationRecipient {
/** /**
* . * .
*/ */
@Index() @Index('IDX_abuse_report_notification_recipient_isActive')
@Column('boolean', { @Column('boolean', {
default: true, default: true,
}) })
@ -47,7 +47,7 @@ export class MiAbuseReportNotificationRecipient {
/** /**
* . * .
*/ */
@Index() @Index('IDX_abuse_report_notification_recipient_method')
@Column('varchar', { @Column('varchar', {
length: 64, length: 64,
}) })
@ -56,7 +56,7 @@ export class MiAbuseReportNotificationRecipient {
/** /**
* ID. * ID.
*/ */
@Index() @Index('IDX_abuse_report_notification_recipient_userId')
@Column({ @Column({
...id(), ...id(),
nullable: true, 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' }) @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' })
public userProfile: MiUserProfile | null; public userProfile: MiUserProfile | null;
/** /**
* WebhookId. * WebhookId.
*/ */
@Index() @Index('IDX_abuse_report_notification_recipient_systemWebhookId')
@Column({ @Column({
...id(), ...id(),
nullable: true, nullable: true,
@ -95,6 +97,8 @@ export class MiAbuseReportNotificationRecipient {
@ManyToOne(type => MiSystemWebhook, { @ManyToOne(type => MiSystemWebhook, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn({
foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId',
})
public systemWebhook: MiSystemWebhook | null; public systemWebhook: MiSystemWebhook | null;
} }

View file

@ -29,6 +29,7 @@ export class MiEmoji {
}) })
public host: string | null; public host: string | null;
@Index('IDX_EMOJI_CATEGORY')
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
}) })
@ -77,6 +78,8 @@ export class MiEmoji {
public isSensitive: boolean; public isSensitive: boolean;
// TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする
// Synchronize: false is needed because TypeORM doesn't understand GIN indexes
@Index('IDX_EMOJI_ROLE_IDS', { synchronize: false })
@Column('varchar', { @Column('varchar', {
array: true, length: 128, default: '{}', array: true, length: 128, default: '{}',
}) })

View file

@ -45,6 +45,7 @@ export class SkLatestNote {
}) })
@JoinColumn({ @JoinColumn({
name: 'user_id', name: 'user_id',
foreignKeyConstraintName: 'FK_20e346fffe4a2174585005d6d80',
}) })
public user: MiUser | null; public user: MiUser | null;
@ -60,6 +61,7 @@ export class SkLatestNote {
}) })
@JoinColumn({ @JoinColumn({
name: 'note_id', name: 'note_id',
foreignKeyConstraintName: 'FK_47a38b1c13de6ce4e5090fb1acd',
}) })
public note: MiNote | null; public note: MiNote | null;

View file

@ -60,7 +60,7 @@ export class MiMeta {
public maintainerEmail: string | null; public maintainerEmail: string | null;
@Column('boolean', { @Column('boolean', {
default: false, default: true,
}) })
public disableRegistration: boolean; public disableRegistration: boolean;
@ -382,6 +382,12 @@ export class MiMeta {
}) })
public swPrivateKey: string | null; public swPrivateKey: string | null;
@Column('integer', {
default: 5000,
comment: 'Timeout in milliseconds for translation API requests',
})
public translationTimeout: number;
@Column('varchar', { @Column('varchar', {
length: 1024, length: 1024,
nullable: true, nullable: true,
@ -425,7 +431,7 @@ export class MiMeta {
@Column('varchar', { @Column('varchar', {
length: 1024, length: 1024,
default: 'https://activitypub.software/TransFem-org/Sharkey/', default: 'https://activitypub.software/TransFem-org/Sharkey/',
nullable: false, nullable: true,
}) })
public repositoryUrl: string | null; public repositoryUrl: string | null;
@ -612,8 +618,8 @@ export class MiMeta {
}) })
public enableAchievements: boolean; public enableAchievements: boolean;
@Column('varchar', { @Column('text', {
length: 2048, nullable: true, nullable: true,
}) })
public robotsTxt: string | null; public robotsTxt: string | null;
@ -643,7 +649,7 @@ export class MiMeta {
public bannedEmailDomains: string[]; public bannedEmailDomains: string[];
@Column('varchar', { @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[]; public preservedUsernames: string[];
@ -658,22 +664,22 @@ export class MiMeta {
public enableFanoutTimelineDbFallback: boolean; public enableFanoutTimelineDbFallback: boolean;
@Column('integer', { @Column('integer', {
default: 300, default: 800,
}) })
public perLocalUserUserTimelineCacheMax: number; public perLocalUserUserTimelineCacheMax: number;
@Column('integer', { @Column('integer', {
default: 100, default: 800,
}) })
public perRemoteUserUserTimelineCacheMax: number; public perRemoteUserUserTimelineCacheMax: number;
@Column('integer', { @Column('integer', {
default: 300, default: 800,
}) })
public perUserHomeTimelineCacheMax: number; public perUserHomeTimelineCacheMax: number;
@Column('integer', { @Column('integer', {
default: 300, default: 800,
}) })
public perUserListTimelineCacheMax: number; public perUserListTimelineCacheMax: number;
@ -689,9 +695,9 @@ export class MiMeta {
@Column('varchar', { @Column('varchar', {
length: 500, length: 500,
nullable: true, default: '❤️',
}) })
public defaultLike: string | null; public defaultLike: string;
@Column('varchar', { @Column('varchar', {
length: 256, array: true, default: '{}', length: 256, array: true, default: '{}',
@ -714,7 +720,7 @@ export class MiMeta {
public urlPreviewMaximumContentLength: number; public urlPreviewMaximumContentLength: number;
@Column('boolean', { @Column('boolean', {
default: true, default: false,
}) })
public urlPreviewRequireContentLength: boolean; public urlPreviewRequireContentLength: boolean;

View file

@ -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 // 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. // 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, // Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production,
@Index(['userId', 'id']) @Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id'])
@Entity('note') @Entity('note')
export class MiNote { export class MiNote {
@PrimaryColumn(id()) @PrimaryColumn(id())
@ -273,3 +273,7 @@ export type IMentionedRemoteUsers = {
username: string; username: string;
host: string; host: string;
}[]; }[];
export function hasText(note: MiNote): note is MiNote & { text: string } {
return note.text != null;
}

View file

@ -129,7 +129,9 @@ export class MiUser {
@OneToOne(() => MiDriveFile, { @OneToOne(() => MiDriveFile, {
onDelete: 'SET NULL', onDelete: 'SET NULL',
}) })
@JoinColumn() @JoinColumn({
foreignKeyConstraintName: 'FK_q5lm0tbgejtfskzg0rc4wd7t1n',
})
public background: MiDriveFile | null; public background: MiDriveFile | null;
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
@ -345,7 +347,7 @@ export class MiUser {
*/ */
@Column('boolean', { @Column('boolean', {
name: 'enable_rss', name: 'enable_rss',
default: true, default: false,
}) })
public enableRss: boolean; public enableRss: boolean;

View file

@ -24,7 +24,9 @@ export class MiUserListMembership {
@ManyToOne(type => MiUser, { @ManyToOne(type => MiUser, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn({
foreignKeyConstraintName: 'FK_d844bfc6f3f523a05189076efaa',
})
public user: MiUser | null; public user: MiUser | null;
@Index() @Index()
@ -37,7 +39,9 @@ export class MiUserListMembership {
@ManyToOne(type => MiUserList, { @ManyToOne(type => MiUserList, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn({
foreignKeyConstraintName: 'FK_605472305f26818cc93d1baaa74',
})
public userList: MiUserList | null; public userList: MiUserList | null;
// タイムラインにその人のリプライまで含めるかどうか // タイムラインにその人のリプライまで含めるかどうか

View file

@ -34,6 +34,7 @@ export class MiUserPending {
@Column('varchar', { @Column('varchar', {
length: 1000, length: 1000,
nullable: true,
}) })
public reason: string; public reason: string;
} }

View file

@ -110,12 +110,14 @@ export class MiUserProfile {
@Column('enum', { @Column('enum', {
enum: followingVisibilities, enum: followingVisibilities,
enumName: 'user_profile_followingVisibility_enum',
default: 'public', default: 'public',
}) })
public followingVisibility: typeof followingVisibilities[number]; public followingVisibility: typeof followingVisibilities[number];
@Column('enum', { @Column('enum', {
enum: followersVisibilities, enum: followersVisibilities,
enumName: 'user_profile_followersVisibility_enum',
default: 'public', default: 'public',
}) })
public followersVisibility: typeof followersVisibilities[number]; public followersVisibility: typeof followersVisibilities[number];

View file

@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = {
optional: false, nullable: false, optional: false, nullable: false,
enum: ['available', 'readonly', 'unavailable'], enum: ['available', 'readonly', 'unavailable'],
}, },
canTrend: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View file

@ -4,9 +4,9 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm'; import { In, MoreThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; 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 type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
@ -17,10 +17,10 @@ import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { ApLogService } from '@/core/ApLogService.js'; import { ApLogService } from '@/core/ApLogService.js';
import { ReactionService } from '@/core/ReactionService.js'; import { ReactionService } from '@/core/ReactionService.js';
import { QueueService } from '@/core/QueueService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js'; import type { DbUserDeleteJobData } from '../types.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class DeleteAccountProcessorService { export class DeleteAccountProcessorService {
@ -45,6 +45,48 @@ export class DeleteAccountProcessorService {
@Inject(DI.noteScheduleRepository) @Inject(DI.noteScheduleRepository)
private noteScheduleRepository: 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 queueService: QueueService,
private driveService: DriveService, private driveService: DriveService,
private emailService: EmailService, private emailService: EmailService,
@ -65,6 +107,140 @@ export class DeleteAccountProcessorService {
return; 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 { // Delete scheduled notes
const scheduledNotes = await this.noteScheduleRepository.findBy({ const scheduledNotes = await this.noteScheduleRepository.findBy({
userId: user.id, userId: user.id,
@ -82,6 +258,10 @@ export class DeleteAccountProcessorService {
} }
{ // Delete notes { // Delete notes
await this.latestNotesRepository.delete({
userId: user.id,
});
let cursor: MiNote['id'] | null = null; let cursor: MiNote['id'] | null = null;
while (true) { while (true) {
@ -102,7 +282,23 @@ export class DeleteAccountProcessorService {
cursor = notes.at(-1)?.id ?? null; 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) { for (const note of notes) {
await this.searchService.unindexNote(note); await this.searchService.unindexNote(note);
@ -119,37 +315,6 @@ export class DeleteAccountProcessorService {
this.logger.succ('All of notes deleted'); 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 { // Delete files
let cursor: MiDriveFile['id'] | null = null; let cursor: MiDriveFile['id'] | null = null;
@ -191,20 +356,42 @@ export class DeleteAccountProcessorService {
this.logger.succ('All AP logs deleted'); this.logger.succ('All AP logs deleted');
} }
{ // Send email notification // Do this BEFORE deleting the account!
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneBy({ userId: user.id });
if (profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, 'Account deleted', { // Delete the actual account
'Your account has been deleted.', await this.userIpsRepository.delete({
'Your account has been deleted.'); 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指定されている場合は物理削除しない { // Send email notification
if (job.data.soft) { if (profile && profile.email && profile.emailVerified) {
// nop try {
} else { await this.emailService.sendEmail(profile.email, 'Account deleted',
await this.usersRepository.delete(job.data.user.id); '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'; return 'Account deleted';

View file

@ -675,9 +675,11 @@ export class FileServerService {
if (info.blocked) { if (info.blocked) {
reply.code(429); reply.code(429);
reply.send({ reply.send({
message: 'Rate limit exceeded. Please try again later.', error: {
code: 'RATE_LIMIT_EXCEEDED', message: 'Rate limit exceeded. Please try again later.',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
},
}); });
return false; return false;

View file

@ -445,6 +445,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
translationTimeout: {
type: 'number',
optional: false, nullable: false,
},
deeplAuthKey: { deeplAuthKey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -477,6 +481,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
defaultLike: {
type: 'string',
optional: false, nullable: false,
},
description: { description: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -741,6 +749,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
objectStorageUseProxy: instance.objectStorageUseProxy, objectStorageUseProxy: instance.objectStorageUseProxy,
objectStorageSetPublicRead: instance.objectStorageSetPublicRead, objectStorageSetPublicRead: instance.objectStorageSetPublicRead,
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
translationTimeout: instance.translationTimeout,
deeplAuthKey: instance.deeplAuthKey, deeplAuthKey: instance.deeplAuthKey,
deeplIsPro: instance.deeplIsPro, deeplIsPro: instance.deeplIsPro,
deeplFreeMode: instance.deeplFreeMode, deeplFreeMode: instance.deeplFreeMode,

View file

@ -12,6 +12,7 @@ import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { notificationRecieveConfig } from '@/models/json-schema/user.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js';
import { isSystemAccount } from '@/misc/is-system-account.js'; import { isSystemAccount } from '@/misc/is-system-account.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = { export const meta = {
tags: ['admin'], 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; } as const;
@ -213,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
private roleEntityService: RoleEntityService, private roleEntityService: RoleEntityService,
private idService: IdService, private idService: IdService,
private readonly cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const [user, profile] = await Promise.all([ 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 roleAssigns = await this.roleService.getUserAssigns(user.id);
const roles = await this.roleService.getUserRoles(user.id); const roles = await this.roleService.getUserRoles(user.id);
const followStats = await this.cacheService.getFollowStats(user.id);
return { return {
email: profile.email, email: profile.email,
emailVerified: profile.emailVerified, emailVerified: profile.emailVerified,
@ -269,6 +303,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
roleId: a.roleId, roleId: a.roleId,
})), })),
followStats: {
...followStats,
totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers),
totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing),
},
}; };
}); });
} }

View file

@ -69,7 +69,7 @@ export const paramDef = {
description: { type: 'string', nullable: true }, description: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true },
defaultLike: { type: 'string', nullable: true }, defaultLike: { type: 'string' },
cacheRemoteFiles: { type: 'boolean' }, cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' },
@ -103,6 +103,7 @@ export const paramDef = {
type: 'string', type: 'string',
}, },
}, },
translationTimeout: { type: 'number' },
deeplAuthKey: { type: 'string', nullable: true }, deeplAuthKey: { type: 'string', nullable: true },
deeplIsPro: { type: 'boolean' }, deeplIsPro: { type: 'boolean' },
deeplFreeMode: { type: 'boolean' }, deeplFreeMode: { type: 'boolean' },
@ -571,6 +572,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle;
} }
if (ps.translationTimeout !== undefined) {
set.translationTimeout = ps.translationTimeout;
}
if (ps.deeplAuthKey !== undefined) { if (ps.deeplAuthKey !== undefined) {
if (ps.deeplAuthKey === '') { if (ps.deeplAuthKey === '') {
set.deeplAuthKey = null; set.deeplAuthKey = null;

View file

@ -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) { 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); this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);

View file

@ -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) { 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); this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);

View file

@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js";
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = { export const meta = {
requireCredential: false, requireCredential: false,
@ -41,6 +42,7 @@ export const paramDef = {
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
trending: { type: 'boolean', default: false },
}, },
required: ['tag', 'sort'], required: ['tag', 'sort'],
} as const; } as const;
@ -52,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private readonly roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); 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; 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' }); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
}); });

View file

@ -117,7 +117,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel')
.andWhere('user.isExplorable = TRUE');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateSuspendedUserQueryForNote(query);

View file

@ -10,22 +10,28 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import type { MiMeta, MiNote } from '@/models/_.js';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.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 = { export const meta = {
tags: ['notes'], tags: ['notes'],
// TODO allow unauthenticated if default template allows?
// Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role.
// This will allow unauthenticated requests without leaking post data to restricted clients.
requireCredential: true, requireCredential: true,
kind: 'read:account', kind: 'read:account',
res: { res: {
type: 'object', type: 'object',
optional: true, nullable: false, optional: false, nullable: false,
properties: { properties: {
sourceLang: { type: 'string' }, sourceLang: { type: 'string', optional: true, nullable: false },
text: { type: 'string' }, text: { type: 'string', optional: true, nullable: false },
}, },
}, },
@ -45,6 +51,11 @@ export const meta = {
code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE', code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE',
id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d', 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 // 10 calls per 5 seconds
@ -73,6 +84,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService, private getterService: GetterService,
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private roleService: RoleService, private roleService: RoleService,
private readonly cacheService: CacheService,
private readonly loggerService: ApiLoggerService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id); 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); throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
} }
if (note.text == null) { if (!hasText(note)) {
return; return {};
} }
const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance; 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; let targetLang = ps.targetLang;
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; 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 // DeepL/DeepLX handling
if (canDeepl) { if (this.serverSettings.deeplAuthKey || deeplFreeInstance) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey); if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
params.append('text', note.text); params.append('text', note.text);
params.append('target_lang', targetLang); 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, { const res = await this.httpRequestService.send(endpoint, {
method: 'POST', method: 'POST',
@ -116,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
Accept: 'application/json, */*', Accept: 'application/json, */*',
}, },
body: params.toString(), body: params.toString(),
timeout: this.serverSettings.translationTimeout,
}); });
if (this.serverSettings.deeplAuthKey) { if (this.serverSettings.deeplAuthKey) {
const json = (await res.json()) as { const json = (await res.json()) as {
@ -151,8 +185,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// LibreTranslate handling // LibreTranslate handling
if (canLibre) { if (this.serverSettings.libreTranslateURL) {
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL as string, { const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -165,6 +199,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
format: 'text', format: 'text',
api_key: this.serverSettings.libreTranslateKey ?? '', api_key: this.serverSettings.libreTranslateKey ?? '',
}), }),
timeout: this.serverSettings.translationTimeout,
}); });
const json = (await res.json()) as { const json = (await res.json()) as {
@ -182,8 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
text: json.translatedText, text: json.translatedText,
}; };
} }
} catch (e) {
this.loggerService.logger.error('Unhandled error from translation API: ', { e });
}
return; return null;
});
} }
} }

View file

@ -4,11 +4,14 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; 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 { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import type { SelectQueryBuilder } from 'typeorm';
export const meta = { export const meta = {
tags: ['users'], tags: ['users'],
@ -38,7 +41,7 @@ export const paramDef = {
properties: { properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 }, 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' }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
hostname: { hostname: {
@ -59,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private queryService: QueryService, private queryService: QueryService,
private readonly roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.usersRepository.createQueryBuilder('user') const query = this.usersRepository.createQueryBuilder('user')
@ -81,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
switch (ps.sort) { switch (ps.sort) {
case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
case '-follower': query.orderBy('user.followersCount', 'ASC'); 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', 'DESC'); break;
case '-createdAt': query.orderBy('user.id', 'ASC'); break; case '-createdAt': query.orderBy('user.id', 'ASC'); break;
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); 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.limit(ps.limit);
query.offset(ps.offset); 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' }); 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"');
}
} }

View file

@ -890,6 +890,7 @@ export class ClientServerService {
return await reply.view('info-card', { return await reply.view('info-card', {
version: this.config.version, version: this.config.version,
host: this.config.host, host: this.config.host,
url: this.config.url,
meta: this.meta, meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),

View file

@ -15,15 +15,21 @@ import type Logger from '@/logger.js';
import { query } from '@/misc/prelude/url.js'; import { query } from '@/misc/prelude/url.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { ApiError } from '@/server/api/error.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { RedisKVCache } from '@/misc/cache.js'; import { RedisKVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.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 { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { SystemAccountService } from '@/core/SystemAccountService.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'; import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & { export type LocalSummalyResult = SummalyResult & {
@ -31,7 +37,27 @@ export type LocalSummalyResult = SummalyResult & {
}; };
// Increment this to invalidate cached previews after a major change. // 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() @Injectable()
export class UrlPreviewService { export class UrlPreviewService {
@ -58,6 +84,9 @@ export class UrlPreviewService {
private readonly apDbResolverService: ApDbResolverService, private readonly apDbResolverService: ApDbResolverService,
private readonly apRequestService: ApRequestService, private readonly apRequestService: ApRequestService,
private readonly systemAccountService: SystemAccountService, private readonly systemAccountService: SystemAccountService,
private readonly apNoteService: ApNoteService,
private readonly authenticateService: AuthenticateService,
private readonly rateLimiterService: SkRateLimiterService,
) { ) {
this.logger = this.loggerService.getLogger('url-preview'); this.logger = this.loggerService.getLogger('url-preview');
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', { this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
@ -85,9 +114,9 @@ export class UrlPreviewService {
@bindThis @bindThis
public async handle( public async handle(
request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>, request: FastifyRequest<PreviewRoute>,
reply: FastifyReply, reply: FastifyReply,
): Promise<object | undefined> { ): Promise<void> {
const url = request.query.url; const url = request.query.url;
if (typeof url !== 'string' || !URL.canParse(url)) { if (typeof url !== 'string' || !URL.canParse(url)) {
reply.code(400); reply.code(400);
@ -101,38 +130,39 @@ export class UrlPreviewService {
} }
if (!this.meta.urlPreviewEnabled) { if (!this.meta.urlPreviewEnabled) {
reply.code(403); return reply.code(403).send({
return { error: {
error: new ApiError({
message: 'URL preview is disabled', message: 'URL preview is disabled',
code: 'URL_PREVIEW_DISABLED', code: 'URL_PREVIEW_DISABLED',
id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', 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)) { if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
reply.code(403); return reply.code(403).send({
return { error: {
error: new ApiError({
message: 'URL is blocked', message: 'URL is blocked',
code: 'URL_PREVIEW_BLOCKED', code: 'URL_PREVIEW_BLOCKED',
id: '50294652-857b-4b13-9700-8e5c7a8deae8', 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 cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
const cached = await this.previewCache.get(cacheKey); if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
if (cached !== undefined) { return;
// 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;
} }
try { try {
@ -144,14 +174,13 @@ export class UrlPreviewService {
// Repeat check, since redirects are allowed. // Repeat check, since redirects are allowed.
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) { if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
reply.code(403); return reply.code(403).send({
return { error: {
error: new ApiError({
message: 'URL is blocked', message: 'URL is blocked',
code: 'URL_PREVIEW_BLOCKED', code: 'URL_PREVIEW_BLOCKED',
id: '50294652-857b-4b13-9700-8e5c7a8deae8', id: '50294652-857b-4b13-9700-8e5c7a8deae8',
}), },
}; });
} }
this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`); this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
@ -164,33 +193,76 @@ export class UrlPreviewService {
await this.inferActivityPubLink(summary); await this.inferActivityPubLink(summary);
} }
if (summary.activityPub) { if (summary.activityPub && !summary.haveNoteLocally) {
// Avoid duplicate checks in case inferActivityPubLink already set this. // 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 to avoid hammering redis when a bunch of URLs are fetched at once
await this.previewCache.set(cacheKey, summary); await this.previewCache.set(cacheKey, summary);
// Cache 1 day (matching redis) // Cache 1 day (matching redis), but only once we finalize the result
reply.header('Cache-Control', 'public, max-age=86400'); if (!summary.activityPub || summary.haveNoteLocally) {
reply.header('Cache-Control', 'public, max-age=86400');
}
return summary; return reply.code(200).send(summary);
} catch (err) { } catch (err) {
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`); this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
reply.code(422);
reply.header('Cache-Control', 'max-age=3600'); reply.header('Cache-Control', 'max-age=3600');
return { return reply.code(422).send({
error: new ApiError({ error: {
message: 'Failed to get preview', message: 'Failed to get preview',
code: 'URL_PREVIEW_FAILED', code: 'URL_PREVIEW_FAILED',
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', 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> { private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const agent = this.config.proxy const agent = this.config.proxy
? { ? {
@ -211,6 +283,7 @@ export class UrlPreviewService {
} }
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> { 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 proxy = meta.urlPreviewSummaryProxyUrl!;
const queryStr = query({ const queryStr = query({
followRedirects: true, followRedirects: true,
@ -302,4 +375,129 @@ export class UrlPreviewService {
return; 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;
}
} }

View file

@ -43,7 +43,7 @@ html
} }
body body
a#a(href=`https://${host}` target="_blank") a#a(href=url target="_blank")
header#banner(style=`background-image: url(${meta.bannerUrl})`) header#banner(style=`background-image: url(${meta.bannerUrl})`)
div#title= meta.name || host div#title= meta.name || host
div#content div#content

View 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();
});
});
});

View file

@ -18,7 +18,7 @@
"@transfem-org/sfm-js": "0.24.5", "@transfem-org/sfm-js": "0.24.5",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.2.3", "@vitejs/plugin-vue": "5.2.3",
"@vue/compiler-sfc": "3.5.13", "@vue/compiler-sfc": "3.5.14",
"astring": "1.9.0", "astring": "1.9.0",
"buraha": "0.0.1", "buraha": "0.0.1",
"estree-walker": "3.0.3", "estree-walker": "3.0.3",
@ -35,7 +35,7 @@
"typescript": "5.8.3", "typescript": "5.8.3",
"uuid": "11.1.0", "uuid": "11.1.0",
"vite": "6.3.4", "vite": "6.3.4",
"vue": "3.5.13" "vue": "3.5.14"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/summaly": "5.2.1", "@misskey-dev/summaly": "5.2.1",
@ -49,7 +49,7 @@
"@typescript-eslint/eslint-plugin": "8.31.0", "@typescript-eslint/eslint-plugin": "8.31.0",
"@typescript-eslint/parser": "8.31.0", "@typescript-eslint/parser": "8.31.0",
"@vitest/coverage-v8": "3.1.2", "@vitest/coverage-v8": "3.1.2",
"@vue/runtime-core": "3.5.13", "@vue/runtime-core": "3.5.14",
"acorn": "8.14.1", "acorn": "8.14.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",

View file

@ -176,6 +176,7 @@ export const ROLE_POLICIES = [
'canImportMuting', 'canImportMuting',
'canImportUserLists', 'canImportUserLists',
'chatAvailability', 'chatAvailability',
'canTrend',
] as const; ] 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']; 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'];

View file

@ -59,6 +59,7 @@ export class I18n<T extends ILocale> {
if (typeof value === 'string') { if (typeof value === 'string') {
const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter); 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) { if (parameters.length) {
console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`); console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
} }

View file

@ -30,7 +30,7 @@
"@transfem-org/sfm-js": "0.24.6", "@transfem-org/sfm-js": "0.24.6",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.2.3", "@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", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"astring": "1.9.0", "astring": "1.9.0",
"broadcast-channel": "7.1.0", "broadcast-channel": "7.1.0",
@ -76,7 +76,7 @@
"uuid": "11.1.0", "uuid": "11.1.0",
"v-code-diff": "1.13.1", "v-code-diff": "1.13.1",
"vite": "6.3.4", "vite": "6.3.4",
"vue": "3.5.13", "vue": "3.5.14",
"vuedraggable": "next", "vuedraggable": "next",
"wanakana": "5.3.1" "wanakana": "5.3.1"
}, },
@ -119,8 +119,8 @@
"@typescript-eslint/eslint-plugin": "8.31.0", "@typescript-eslint/eslint-plugin": "8.31.0",
"@typescript-eslint/parser": "8.31.0", "@typescript-eslint/parser": "8.31.0",
"@vitest/coverage-v8": "3.1.2", "@vitest/coverage-v8": "3.1.2",
"@vue/compiler-core": "3.5.13", "@vue/compiler-core": "3.5.14",
"@vue/runtime-core": "3.5.13", "@vue/runtime-core": "3.5.14",
"acorn": "8.14.1", "acorn": "8.14.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",

View file

@ -23,10 +23,10 @@ import type MkNote from '@/components/MkNote.vue';
import type SkNote from '@/components/SkNote.vue'; import type SkNote from '@/components/SkNote.vue';
import { prefer } from '@/preferences'; import { prefer } from '@/preferences';
const XNote = computed(() => const XNote = defineAsyncComponent(() =>
prefer.r.noteDesign.value === 'misskey' prefer.s.noteDesign === 'misskey'
? defineAsyncComponent(() => import('@/components/MkNote.vue')) ? import('@/components/MkNote.vue')
: defineAsyncComponent(() => import('@/components/SkNote.vue')), : import('@/components/SkNote.vue')
); );
const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl'); const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl');

View file

@ -20,10 +20,10 @@ import type MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import type SkNoteDetailed from '@/components/SkNoteDetailed.vue'; import type SkNoteDetailed from '@/components/SkNoteDetailed.vue';
import { prefer } from '@/preferences'; import { prefer } from '@/preferences';
const XNoteDetailed = computed(() => const XNoteDetailed = defineAsyncComponent(() =>
prefer.r.noteDesign.value === 'misskey' prefer.s.noteDesign === 'misskey'
? defineAsyncComponent(() => import('@/components/MkNoteDetailed.vue')) ? import('@/components/MkNoteDetailed.vue')
: defineAsyncComponent(() => import('@/components/SkNoteDetailed.vue')), : import('@/components/SkNoteDetailed.vue'),
); );
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl'); const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl');

View file

@ -21,10 +21,10 @@ import type MkNoteSimple from '@/components/MkNoteSimple.vue';
import type SkNoteSimple from '@/components/SkNoteSimple.vue'; import type SkNoteSimple from '@/components/SkNoteSimple.vue';
import { prefer } from '@/preferences'; import { prefer } from '@/preferences';
const XNoteSimple = computed(() => const XNoteSimple = defineAsyncComponent(() =>
prefer.r.noteDesign.value === 'misskey' prefer.s.noteDesign === 'misskey'
? defineAsyncComponent(() => import('@/components/MkNoteSimple.vue')) ? import('@/components/MkNoteSimple.vue')
: defineAsyncComponent(() => import('@/components/SkNoteSimple.vue')), : import('@/components/SkNoteSimple.vue'),
); );
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl'); const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl');

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button <button
v-if="!link" v-if="!link"
ref="el" class="_button" 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" :type="type"
:name="name" :name="name"
:value="value" :value="value"
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button> </button>
<MkA <MkA
v-else class="_button" 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 ?? '#'" :to="to ?? '#'"
:behavior="linkBehavior" :behavior="linkBehavior"
@mousedown="onMousedown" @mousedown="onMousedown"
@ -48,6 +48,7 @@ const props = defineProps<{
linkBehavior?: null | 'window' | 'browser'; linkBehavior?: null | 'window' | 'browser';
autofocus?: boolean; autofocus?: boolean;
wait?: boolean; wait?: boolean;
accent?: boolean;
danger?: boolean; danger?: boolean;
full?: boolean; full?: boolean;
small?: 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 { &.danger {
font-weight: bold; font-weight: bold;
color: var(--MI_THEME-error); color: var(--MI_THEME-error);

View file

@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@leave="leave" @leave="leave"
@afterLeave="afterLeave" @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> <slot></slot>
<button v-if="omitted" :class="$style.fade" class="_button" @click="showMore"> <button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
@ -228,6 +228,11 @@ onUnmounted(() => {
*/ */
background: var(--MI_THEME-panel); background: var(--MI_THEME-panel);
&.naked {
background: transparent !important;
box-shadow: none !important;
}
&.omitted { &.omitted {
position: relative; position: relative;
max-height: var(--maxHeight); max-height: var(--maxHeight);

View file

@ -86,13 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:isBlock="true" :isBlock="true"
class="_selectable" class="_selectable"
/> />
<div v-if="translating || translation" :class="$style.translation"> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<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-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> <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> </div>
@ -101,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </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/> <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"> <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>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></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"> <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> <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> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button> </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> <i class="ti ti-paperclip"></i>
</button> </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> <i class="ti ti-dots"></i>
</button> </button>
</footer> </footer>
@ -171,24 +168,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</article> </article>
</div> </div>
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small"> <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
<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>
</div> </div>
<div v-else> <div v-else>
<!-- <!--
@ -204,7 +184,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.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 { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
@ -224,7 +204,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { pleaseLogin } from '@/utility/please-login.js'; 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 { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import number from '@/filters/number.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 { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { i18n } from '@/i18n.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 { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.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 MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.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 { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js'; import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { useRouter } from '@/router.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<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -272,8 +256,6 @@ const emit = defineEmits<{
const router = useRouter(); 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 inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', 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 isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW); const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); 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 isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const renoted = ref(false); const renoted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); 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)); 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>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', 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 mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionLint
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; let renoting = false;
const keymap = { const keymap = {
@ -403,6 +359,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return; if (!prefer.s.showClipButtonInNoteFooter) return;
clip(); clip();
}, },
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => { 'o': () => {
if (renoteCollapsed.value) return; if (renoteCollapsed.value) return;
galleryEl.value?.openGallery(); 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); 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 { function showRenoteMenu(): void {
if (props.mock) { if (props.mock) {
return; return;
@ -1199,13 +1166,6 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-right: 0.5em; margin-right: 0.5em;
} }
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.urlPreview { .urlPreview {
margin-top: 8px; margin-top: 8px;
} }

View file

@ -104,13 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_selectable" class="_selectable"
/> />
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation"> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<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-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> <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"> <div v-if="appearNote.files && appearNote.files.length > 0">
@ -118,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </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"/> <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"> <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>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div> </div>
@ -172,10 +166,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ph-smiley ph-bold ph-lg"></i> <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> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button> </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> <i class="ti ti-paperclip"></i>
</button> </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> <i class="ti ti-dots"></i>
</button> </button>
</footer> </footer>
@ -230,11 +227,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false"> <div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div> </div>
</template> </template>
@ -243,7 +236,7 @@ import { computed, inject, onMounted, provide, ref, useTemplateRef, watch } from
import * as mfm from '@transfem-org/sfm-js'; import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.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 { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue'; import type { Paging } from '@/components/MkPagination.vue';
@ -260,7 +253,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js'; 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 { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js'; import { notePage } from '@/filters/note.js';
import number from '@/filters/number.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 { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { i18n } from '@/i18n.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 { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useNoteCapture } from '@/use/use-note-capture.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 MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; 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 { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.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<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -340,12 +337,12 @@ const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW); const showContent = ref(prefer.s.uncollapseCW);
const isDeleted = ref(false); const isDeleted = ref(false);
const renoted = ref(false); const renoted = ref(false);
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const parsed = computed(() => 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 urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : 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 allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]); const conversation = ref<Misskey.entities.Note[]>([]);
@ -358,6 +355,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
const { muted } = checkMutes(appearNote.value);
watch(() => props.expandAllCws, (expandAllCws) => { watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws; if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
}); });
@ -376,7 +375,7 @@ let renoting = false;
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', 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 = { const keymap = {
@ -388,6 +387,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return; if (!prefer.s.showClipButtonInNoteFooter) return;
clip(); clip();
}, },
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => galleryEl.value?.openGallery(), 'o': () => galleryEl.value?.openGallery(),
'v|enter': () => { 'v|enter': () => {
if (appearNote.value.cw != null) { 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); 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 { function showRenoteMenu(): void {
if (!isMyRenote) return; if (!isMyRenote) return;
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
@ -1044,13 +1052,6 @@ function animatedMFM() {
color: var(--MI_THEME-renote); 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 { .poll {
font-size: 80%; font-size: 80%;
} }

View file

@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_button" class="_button"
:class="$style.noteFooterButton" :class="$style.noteFooterButton"
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''" :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> <i class="ph-rocket-launch ph-bold ph-lg"></i>
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> <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" ref="quoteButton"
class="_button" class="_button"
:class="$style.noteFooterButton" :class="$style.noteFooterButton"
@mousedown="quote()" @click.stop="quote()"
> >
<i class="ph-quotes ph-bold ph-lg"></i> <i class="ph-quotes ph-bold ph-lg"></i>
</button> </button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled> <button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ph-prohibit ph-bold ph-lg"></i> <i class="ph-prohibit ph-bold ph-lg"></i>
</button> </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> <i class="ph-heart ph-bold ph-lg"></i>
</button> </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-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
<i v-else class="ph-smiley ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i>
</button> </button>
<button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> <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> <i class="ph-minus ph-bold ph-lg"></i>
</button> </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> <i class="ph-dots-three ph-bold ph-lg"></i>
</button> </button>
</footer> </footer>
@ -73,19 +79,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div v-else :class="$style.muted" @click="muted = false"> <div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div> </div>
</template> </template>
<script lang="ts" setup> <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 * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.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 { Visibility } from '@/utility/boost-quote.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
@ -99,16 +102,18 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { userPage } from '@/filters/user.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 { pleaseLogin } from '@/utility/please-login.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { reactionPicker } from '@/utility/reaction-picker.js'; import { reactionPicker } from '@/utility/reaction-picker.js';
import { claimAchievement } from '@/utility/achievements.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 { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance } from '@/instance';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; 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 canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
const muted = computed(() => $i ? checkWordMute(props.note, $i, $i.mutedWords) : false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const renoted = ref(false); const renoted = ref(false);
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = useTemplateRef('clipButton');
const renoteButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>();
const quoteButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>();
@ -154,9 +159,11 @@ const isRenote = (
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', 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) { async function addReplyTo(replyNote: Misskey.entities.Note) {
replies.value.unshift(replyNote); replies.value.unshift(replyNote);
appearNote.value.repliesCount += 1; appearNote.value.repliesCount += 1;
@ -170,6 +177,8 @@ async function removeReply(id: Misskey.entities.Note['id']) {
} }
} }
const { muted } = checkMutes(appearNote.value);
useNoteCapture({ useNoteCapture({
rootEl: el, rootEl: el,
note: appearNote, note: appearNote,
@ -378,6 +387,14 @@ function menu(): void {
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); 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) { if (props.detail) {
misskeyApi('notes/children', { misskeyApi('notes/children', {
noteId: props.note.id, noteId: props.note.id,

View file

@ -240,13 +240,18 @@ watch(props, async () => {
const type = props.notification.type; const type = props.notification.type;
// To avoid extra lookups, only do the query when it actually matters. // To avoid extra lookups, only do the query when it actually matters.
if (type === 'follow' || type === 'receiveFollowRequest') { if ((type === 'follow' || type === 'receiveFollowRequest') && props.notification.userId) {
const user = await misskeyApi('users/show', { try {
userId: props.notification.userId, const user = await misskeyApi('users/show', {
}); userId: props.notification.userId,
});
userDetailed.value = user; userDetailed.value = user;
followRequestDone.value = !user.hasPendingFollowRequestToYou; followRequestDone.value = !user.hasPendingFollowRequestToYou;
} catch {
userDetailed.value = null;
followRequestDone.value = false;
}
} else { } else {
userDetailed.value = null; userDetailed.value = null;
followRequestDone.value = false; followRequestDone.value = false;

View file

@ -18,10 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass=" $style.transition_x_move" :moveClass=" $style.transition_x_move"
tag="div" 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"/> <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"/> <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template> </div>
</component> </component>
</template> </template>
</MkPagination> </MkPagination>

View file

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
import number from '@/filters/number.js'; import number from '@/filters/number.js';
import { prefer } from '@/preferences';
const props = defineProps<{ const props = defineProps<{
value: number; 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, immediate: true,
}); });

View file

@ -274,18 +274,16 @@ const fetchMore = async (): Promise<void> => {
if (res.length === 0) { if (res.length === 0) {
if (props.pagination.reversed) { if (props.pagination.reversed) {
reverseConcat(res).then(() => { await reverseConcat(res);
more.value = false; more.value = false;
});
} else { } else {
items.value = concatMapWithArray(items.value, res); items.value = concatMapWithArray(items.value, res);
more.value = false; more.value = false;
} }
} else { } else {
if (props.pagination.reversed) { if (props.pagination.reversed) {
reverseConcat(res).then(() => { await reverseConcat(res);
more.value = true; more.value = true;
});
} else { } else {
items.value = concatMapWithArray(items.value, res); items.value = concatMapWithArray(items.value, res);
more.value = true; more.value = true;

View file

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; 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 { useInterval } from '@@/js/use-interval.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { sum } from '@/utility/array.js'; import { sum } from '@/utility/array.js';
@ -72,7 +72,7 @@ const showResult = ref(props.readOnly || isVoted.value);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', type: 'lookup',
url: `https://${host}/notes/${props.noteId}`, url: `${config.url}/notes/${props.noteId}`,
})); }));
// //

View file

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div" :class="$style.root" 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"/> <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> </component>
</template> </template>

View file

@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
role: Misskey.entities.Role; role: Misskey.entities.Role;
forModeration: boolean; forModeration: boolean;
detailed: boolean; detailed?: boolean;
}>(), { }>(), {
detailed: true, detailed: true,
}); });

View file

@ -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"/> <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-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> <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"> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA>
</div> </div>
<details v-if="note.files && note.files.length > 0" :open="!prefer.s.collapseFiles && !hideFiles"> <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 { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
const props = defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
translating?: boolean; translating?: boolean;
translation?: any; translation?: Misskey.entities.NotesTranslateResponse | false | null;
hideFiles?: boolean; hideFiles?: boolean;
expandAllCws?: boolean; expandAllCws?: boolean;
}>(); }>(), {
translating: false,
translation: null,
hideFiles: false,
expandAllCws: false,
});
const router = useRouter(); const router = useRouter();
@ -140,13 +140,6 @@ watch(() => props.expandAllCws, (expandAllCws) => {
color: var(--MI_THEME-renote); 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 { .showLess {
width: 100%; width: 100%;
margin-top: 14px; margin-top: 14px;

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="$style.transition_x_move" :moveClass="$style.transition_x_move"
tag="div" 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"> <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<DynamicNote :class="$style.note" :note="note" :withHardMute="true"/> <DynamicNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad"> <div :class="$style.ad">
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> <DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template> </div>
</component> </component>
</template> </template>
</MkPagination> </MkPagination>

View file

@ -71,8 +71,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }} <i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
</MkButton> </MkButton>
</div> </div>
<div v-if="showAsQuote && activityPub && !theNote && !fetchingTheNote" :class="$style.action"> <div v-if="showAsQuote && activityPub && !theNote && $i" :class="$style.action">
<MkButton :small="true" inline @click="fetchNote()"> <MkButton :small="true" :disabled="!!fetching || fetchingTheNote" inline @click="() => refresh(true)">
<i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }} <i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }}
</MkButton> </MkButton>
</div> </div>
@ -93,6 +93,7 @@ import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
import { url as local } from '@@/js/config.js'; import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js'; import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { maybeMakeRelative } from '@@/js/url.js';
import type { summaly } from '@misskey-dev/summaly'; import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -104,7 +105,7 @@ import { prefer } from '@/preferences.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import { warningExternalWebsite } from '@/utility/warning-external-website.js';
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { maybeMakeRelative } from '@@/js/url.js'; import { $i } from '@/i';
type SummalyResult = Awaited<ReturnType<typeof summaly>>; type SummalyResult = Awaited<ReturnType<typeof summaly>>;
@ -131,7 +132,7 @@ const maybeRelativeUrl = maybeMakeRelative(props.url, local);
const self = maybeRelativeUrl !== props.url; const self = maybeRelativeUrl !== props.url;
const attr = self ? 'to' : 'href'; const attr = self ? 'to' : 'href';
const target = self ? null : '_blank'; const target = self ? null : '_blank';
const fetching = ref(true); const fetching = ref<Promise<void> | null>(null);
const title = ref<string | null>(null); const title = ref<string | null>(null);
const description = ref<string | null>(null); const description = ref<string | null>(null);
const thumbnail = 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 sitename = ref<string | null>(null);
const sensitive = ref<boolean>(false); const sensitive = ref<boolean>(false);
const activityPub = ref<string | null>(null); const activityPub = ref<string | null>(null);
const player = ref({ const player = ref<SummalyResult['player']>({
url: null, url: null,
width: null, width: null,
height: null, height: null,
} as SummalyResult['player']); allow: [],
});
const playerEnabled = ref(false); const playerEnabled = ref(false);
const tweetId = ref<string | null>(null); const tweetId = ref<string | null>(null);
const tweetExpanded = ref(props.detail); const tweetExpanded = ref(props.detail);
@ -173,14 +175,14 @@ async function fetchNote() {
return; return;
} }
theNote.value = response['object']; theNote.value = response['object'];
fetchingTheNote.value = false;
} catch (err) { } catch (err) {
if (_DEV_) { if (_DEV_) {
console.error(`failed to extract note for preview of ${activityPub.value}`, err); console.error(`failed to extract note for preview of ${activityPub.value}`, err);
} }
activityPub.value = null; activityPub.value = null;
fetchingTheNote.value = false;
theNote.value = null; theNote.value = null;
} finally {
fetchingTheNote.value = false;
} }
} }
@ -198,39 +200,52 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
requestUrl.hash = ''; requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) function refresh(withFetch = false) {
.then(res => { const params = new URLSearchParams({
if (!res.ok) { url: requestUrl.href,
if (_DEV_) { lang: versatileLang,
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();
}
}); });
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) { function adjustTweetHeight(message: MessageEvent) {
if (message.origin !== 'https://platform.twitter.com') return; if (message.origin !== 'https://platform.twitter.com') return;
@ -256,6 +271,9 @@ window.addEventListener('message', adjustTweetHeight);
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('message', adjustTweetHeight); window.removeEventListener('message', adjustTweetHeight);
}); });
// Load initial data
refresh();
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <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"> <div :class="$style.avatar">
<MkAvatar :class="$style.icon" :user="note.user" indictor/> <MkAvatar :class="$style.icon" :user="note.user" indictor/>
</div> </div>
@ -18,11 +18,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA> </MkA>
</header> </header>
<div> <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"/> <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>
</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> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -30,19 +38,19 @@ import * as Misskey from 'misskey-js';
import { getNoteSummary } from '@/utility/get-note-summary.js'; import { getNoteSummary } from '@/utility/get-note-summary.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.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, note: Misskey.entities.Note,
isMuted: boolean }>();
}>(), {
isMuted: false,
});
defineEmits<{ defineEmits<{
(event: 'select', user: Misskey.entities.UserLite): void (event: 'select', user: Misskey.entities.UserLite): void
}>(); }>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const { muted, hardMuted } = checkMutes(props.note);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }"> <template #default="{ items: notes }">
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true"> <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> </MkDateSeparatedList>
</template> </template>
</MkPagination> </MkPagination>
@ -18,16 +18,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, shallowRef } from 'vue'; import { computed, shallowRef } from 'vue';
import type { FollowingFeedTab } from '@/types/following-feed.js';
import type { Paging } from '@/components/MkPagination.vue'; 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 { i18n } from '@/i18n.js';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.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'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const props = defineProps<{ const props = defineProps<{
@ -78,37 +76,6 @@ const latestNotesPagination: Paging<'notes/following'> = {
}; };
const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>(); 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> </script>
<style module lang="scss"> <style module lang="scss">

View 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>

View file

@ -88,13 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:isAnim="allowAnim" :isAnim="allowAnim"
:isBlock="true" :isBlock="true"
/> />
<div v-if="translating || translation" :class="$style.translation"> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<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-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> <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> </div>
@ -103,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </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/> <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"> <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>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></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"> <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> <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> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button> </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> <i class="ti ti-paperclip"></i>
</button> </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> <i class="ti ti-dots"></i>
</button> </button>
</footer> </footer>
@ -172,24 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</article> </article>
</div> </div>
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small"> <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
<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>
</div> </div>
<div v-else> <div v-else>
<!-- <!--
@ -205,7 +185,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.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 { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
@ -224,7 +204,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { pleaseLogin } from '@/utility/please-login.js'; 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 { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import number from '@/filters/number.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 { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { i18n } from '@/i18n.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 { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.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 MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.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 { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js'; import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { useRouter } from '@/router.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<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -272,8 +256,6 @@ const emit = defineEmits<{
const router = useRouter(); 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 inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', 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 isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW); const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); 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 isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const renoted = ref(false); const renoted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); 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)); 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>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', 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 mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionLint
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; let renoting = false;
const keymap = { const keymap = {
@ -403,6 +359,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return; if (!prefer.s.showClipButtonInNoteFooter) return;
clip(); clip();
}, },
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => { 'o': () => {
if (renoteCollapsed.value) return; if (renoteCollapsed.value) return;
galleryEl.value?.openGallery(); 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); 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 { function showRenoteMenu(): void {
if (props.mock) { if (props.mock) {
return; return;
@ -1233,13 +1200,6 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-right: 0.5em; margin-right: 0.5em;
} }
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.urlPreview { .urlPreview {
margin-top: 8px; margin-top: 8px;
} }

View file

@ -109,13 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_selectable" class="_selectable"
/> />
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation"> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<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-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> <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"> <div v-if="appearNote.files && appearNote.files.length > 0">
@ -123,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </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"/> <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"> <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>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div> </div>
@ -177,10 +171,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ph-smiley ph-bold ph-lg"></i> <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> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button> </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> <i class="ti ti-paperclip"></i>
</button> </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> <i class="ti ti-dots"></i>
</button> </button>
</footer> </footer>
@ -235,11 +232,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false"> <div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div> </div>
</template> </template>
@ -248,7 +241,7 @@ import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, useT
import * as mfm from '@transfem-org/sfm-js'; import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.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 { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue'; import type { Paging } from '@/components/MkPagination.vue';
@ -265,7 +258,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue';
import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; import SkInstanceTicker from '@/components/SkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js'; 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 { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js'; import { notePage } from '@/filters/note.js';
import number from '@/filters/number.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 { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { i18n } from '@/i18n.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 { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useNoteCapture } from '@/use/use-note-capture.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 MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; 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 { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.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<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -346,12 +343,12 @@ const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW); const showContent = ref(prefer.s.uncollapseCW);
const isDeleted = ref(false); const isDeleted = ref(false);
const renoted = ref(false); const renoted = ref(false);
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const parsed = computed(() => 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 urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : 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 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 showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]); const conversation = ref<Misskey.entities.Note[]>([]);
@ -364,6 +361,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
const { muted } = checkMutes(appearNote.value);
watch(() => props.expandAllCws, (expandAllCws) => { watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws; if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
}); });
@ -382,7 +381,7 @@ let renoting = false;
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', 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 = { const keymap = {
@ -394,6 +393,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return; if (!prefer.s.showClipButtonInNoteFooter) return;
clip(); clip();
}, },
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => galleryEl.value?.openGallery(), 'o': () => galleryEl.value?.openGallery(),
'v|enter': () => { 'v|enter': () => {
if (appearNote.value.cw != null) { 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); 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 { function showRenoteMenu(): void {
if (!isMyRenote) return; if (!isMyRenote) return;
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
@ -1102,13 +1110,6 @@ onUnmounted(() => {
color: var(--MI_THEME-renote); 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 { .poll {
font-size: 80%; font-size: 80%;
} }

View file

@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_button" class="_button"
:class="$style.noteFooterButton" :class="$style.noteFooterButton"
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''" :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> <i class="ph-rocket-launch ph-bold ph-lg"></i>
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> <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" ref="quoteButton"
class="_button" class="_button"
:class="$style.noteFooterButton" :class="$style.noteFooterButton"
@mousedown="quote()" @click.stop="quote()"
> >
<i class="ph-quotes ph-bold ph-lg"></i> <i class="ph-quotes ph-bold ph-lg"></i>
</button> </button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled> <button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ph-prohibit ph-bold ph-lg"></i> <i class="ph-prohibit ph-bold ph-lg"></i>
</button> </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> <i class="ph-heart ph-bold ph-lg"></i>
</button> </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-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
<i v-else class="ph-smiley ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i>
</button> </button>
<button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> <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> <i class="ph-minus ph-bold ph-lg"></i>
</button> </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> <i class="ph-dots-three ph-bold ph-lg"></i>
</button> </button>
</footer> </footer>
@ -81,19 +87,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div v-else :class="$style.muted" @click="muted = false"> <div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div> </div>
</template> </template>
<script lang="ts" setup> <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 * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.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 { Visibility } from '@/utility/boost-quote.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import SkNoteHeader from '@/components/SkNoteHeader.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue';
@ -107,16 +110,18 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { userPage } from '@/filters/user.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 { pleaseLogin } from '@/utility/please-login.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { reactionPicker } from '@/utility/reaction-picker.js'; import { reactionPicker } from '@/utility/reaction-picker.js';
import { claimAchievement } from '@/utility/achievements.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 { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance } from '@/instance';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -140,12 +145,12 @@ const canRenote = computed(() => ['public', 'home'].includes(props.note.visibili
const hideLine = computed(() => props.detail); const hideLine = computed(() => props.detail);
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const renoted = ref(false); const renoted = ref(false);
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = useTemplateRef('clipButton');
const renoteButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>();
const quoteButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>();
@ -168,9 +173,11 @@ const isRenote = (
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', 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) { async function addReplyTo(replyNote: Misskey.entities.Note) {
replies.value.unshift(replyNote); replies.value.unshift(replyNote);
appearNote.value.repliesCount += 1; appearNote.value.repliesCount += 1;
@ -184,6 +191,8 @@ async function removeReply(id: Misskey.entities.Note['id']) {
} }
} }
const { muted } = checkMutes(appearNote.value);
useNoteCapture({ useNoteCapture({
rootEl: el, rootEl: el,
note: appearNote, note: appearNote,
@ -392,6 +401,14 @@ function menu(): void {
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); 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) { if (props.detail) {
misskeyApi('notes/children', { misskeyApi('notes/children', {
noteId: props.note.id, noteId: props.note.id,

View 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>

View file

@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></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> </div>
</header> </header>
<div :class="$style.noteContent"> <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> <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"/> <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> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation"> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<div v-if="appearNote.files && appearNote.files.length > 0"> <div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/> <MkMediaList :mediaList="appearNote.files"/>
</div> </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"/> <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 v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</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> <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 MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import { dateTimeFormat } from '@/utility/intl-const.js'; import { dateTimeFormat } from '@/utility/intl-const.js';
import { prefer } from '@/preferences'; import { prefer } from '@/preferences';
import { getPluginHandlers } from '@/plugin.js'; 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<{ const props = defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -146,14 +142,14 @@ const isRenote = (
); );
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
let appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null;
const showContent = ref(false); const showContent = ref(false);
const translation = ref(null); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false); 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); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
</script> </script>
@ -259,13 +255,6 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
color: var(--MI_THEME-renote); 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 { .poll {
font-size: 80%; font-size: 80%;
} }

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.wordMuteTestDescription }}</template> <template #caption>{{ i18n.ts.wordMuteTestDescription }}</template>
</MkTextarea> </MkTextarea>
<div><MkButton :disabled="!testWords" @click="testWordMutes">{{ i18n.ts.wordMuteTestTest }}</MkButton></div> <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-if="testMatches === ''">{{ i18n.ts.wordMuteTestNoMatch }}</div>
<div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div> <div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div>
</div> </div>
@ -44,7 +44,7 @@ function testWordMutes() {
try { try {
const mutes = parseMutes(props.mutedWords); const mutes = parseMutes(props.mutedWords);
const matches = checkWordMute(testWords.value, null, mutes); const matches = checkWordMute(testWords.value, null, mutes);
testMatches.value = matches ? matches.flat(2).join(', ') : ''; testMatches.value = matches ? matches.join(', ') : '';
} catch { } catch {
// Error is displayed by above function // Error is displayed by above function
testMatches.value = null; testMatches.value = null;

View file

@ -7,38 +7,49 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <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;"> <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init"> <FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_gaps_m"> <div v-if="tab === 'overview'" class="_gaps">
<div class="aeakzknw"> <div v-if="user" class="aeakzknw">
<MkAvatar class="avatar" :user="user" indicator link preview/> <MkAvatar class="avatar" :user="user" indicator link preview/>
<div class="body"> <div class="body">
<span class="name"><MkUserName class="name" :user="user"/></span> <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 class="state">
<span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span> <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="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span>
<span v-if="suspended" class="suspended">Suspended</span> <span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span>
<span v-if="silenced" class="silenced">Silenced</span> <span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span>
<span v-if="moderator" class="moderator">Moderator</span> <span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span>
</span> </span>
</div> </div>
</div> </div>
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> <MkFolder v-if="!isSystem">
<template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template>
<div style="display: flex; flex-direction: column; gap: 1em;"> <template #label>{{ i18n.ts.details }}</template>
<MkKeyValue :copy="user.id" oneline> <div style="display: flex; flex-direction: column; gap: 1em;">
<template #key>ID</template> <MkKeyValue v-if="user" :copy="user.id" oneline>
<template #value><span class="_monospace">{{ user.id }}</span></template> <template #key>{{ i18n.ts.id }}</template>
</MkKeyValue> <template #value><span class="_monospace">{{ user.id }}</span></template>
<!-- 要る </MkKeyValue>
<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline> <MkKeyValue v-if="user" :copy="'@' + acct(user)" oneline>
<template #key>IP (recent)</template> <template #key>{{ i18n.ts.username }}</template>
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template> <template #value><span class="_monospace">@{{ acct(user) }}</span></template>
</MkKeyValue> </MkKeyValue>
--> <!-- 要る
<template v-if="!isSystem"> <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> <MkKeyValue oneline>
<template #key>{{ i18n.ts.createdAt }}</template> <template #key>{{ i18n.ts.createdAt }}</template>
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></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 #key>{{ i18n.ts.email }}</template>
<template #value><span class="_monospace">{{ info.email }}</span></template> <template #value><span class="_monospace">{{ info.email }}</span></template>
</MkKeyValue> </MkKeyValue>
</template> <MkKeyValue v-if="info" oneline>
</div> <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 #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template> <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
</MkTextarea> <template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
</MkFolder>
<FormSection v-if="user.host"> <FormSection v-if="user?.host">
<template #label>ActivityPub</template> <template #label>{{ i18n.ts.activityPub }}</template>
<div class="_gaps_m"> <div class="_gaps_m">
<div style="display: flex; flex-direction: column; gap: 1em;"> <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> <template #value><MkTime mode="detail" :time="user.lastFetchedAt"/></template>
</MkKeyValue> </MkKeyValue>
</div> </div>
<MkButton @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
</div> </div>
</FormSection> </FormSection>
<FormSection v-if="!isSystem"> <FormSection v-if="!isSystem && user && iAmModerator">
<div class="_gaps"> <div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> <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> <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> <template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput> </MkInput>
<div> <div :class="$style.buttonStrip">
<MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> <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> </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> </div>
</FormSection> </FormSection>
</div> </div>
<div v-else-if="tab === 'roles'" class="_gaps"> <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 v-for="role in info.roles" :key="role.id">
<div :class="$style.roleItemMain"> <div :class="$style.roleItemMain">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button> <button class="_button" @click="toggleRoleItem(role)">
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> <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> <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div> </div>
<div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub"> <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> <template v-if="info.roleAssigns.some(a => a.roleId === role.id)">
<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>{{ i18n.ts.roleAssigned }}: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</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> </div>
</div> </div>
@ -231,6 +270,8 @@ import { iAmAdmin, $i, iAmModerator } from '@/i.js';
import MkRolePreview from '@/components/MkRolePreview.vue'; import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkNumber from '@/components/MkNumber.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
userId: string; userId: string;
@ -740,4 +781,12 @@ definePage(() => ({
border-radius: var(--MI-radius-sm); border-radius: var(--MI-radius-sm);
cursor: pointer; cursor: pointer;
} }
.buttonStrip {
margin: calc(var(--MI-margin) / 2 * -1);
>* {
margin: calc(var(--MI-margin) / 2);
}
}
</style> </style>

View file

@ -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;"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init"> <FormSuspense :p="init">
<div class="_gaps_m"> <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> <MkFolder>
<template #label>DeepL Translation</template> <template #label>DeepL Translation</template>
@ -69,6 +74,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
const translationTimeout = ref(0);
const deeplAuthKey = ref<string | null>(''); const deeplAuthKey = ref<string | null>('');
const deeplIsPro = ref<boolean>(false); const deeplIsPro = ref<boolean>(false);
const deeplFreeMode = ref<boolean>(false); const deeplFreeMode = ref<boolean>(false);
@ -78,6 +84,7 @@ const libreTranslateKey = ref<string | null>('');
async function init() { async function init() {
const meta = await misskeyApi('admin/meta'); const meta = await misskeyApi('admin/meta');
translationTimeout.value = meta.translationTimeout;
deeplAuthKey.value = meta.deeplAuthKey; deeplAuthKey.value = meta.deeplAuthKey;
deeplIsPro.value = meta.deeplIsPro; deeplIsPro.value = meta.deeplIsPro;
deeplFreeMode.value = meta.deeplFreeMode; deeplFreeMode.value = meta.deeplFreeMode;
@ -86,6 +93,13 @@ async function init() {
libreTranslateKey.value = meta.libreTranslateKey; libreTranslateKey.value = meta.libreTranslateKey;
} }
async function saveTranslationTimeout() {
await os.apiWithDialog('admin/update-meta', {
translationTimeout: translationTimeout.value,
});
await os.promiseDialog(fetchInstance(true));
}
function save_deepl() { function save_deepl() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
deeplAuthKey: deeplAuthKey.value, deeplAuthKey: deeplAuthKey.value,
@ -93,7 +107,7 @@ function save_deepl() {
deeplFreeMode: deeplFreeMode.value, deeplFreeMode: deeplFreeMode.value,
deeplFreeInstance: deeplFreeInstance.value, deeplFreeInstance: deeplFreeInstance.value,
}).then(() => { }).then(() => {
fetchInstance(true); os.promiseDialog(fetchInstance(true));
}); });
} }
@ -102,7 +116,7 @@ function save_libre() {
libreTranslateURL: libreTranslateURL.value, libreTranslateURL: libreTranslateURL.value,
libreTranslateKey: libreTranslateKey.value, libreTranslateKey: libreTranslateKey.value,
}).then(() => { }).then(() => {
fetchInstance(true); os.promiseDialog(fetchInstance(true));
}); });
} }

View file

@ -797,6 +797,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange> </MkRange>
</div> </div>
</MkFolder> </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> </div>
</FormSlot> </FormSlot>
</div> </div>

View file

@ -300,6 +300,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.enable }}</template> <template #label>{{ i18n.ts.enable }}</template>
</MkSwitch> </MkSwitch>
</MkFolder> </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> </div>
</MkFolder> </MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>

View file

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/> />
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
</MkFukidashi> </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"> <div :class="$style.footer">
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button> <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
<MkTime :class="$style.time" :time="message.createdAt"/> <MkTime :class="$style.time" :time="message.createdAt"/>

View file

@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="prefer.s.animation ? $style.transition_x_move : ''" :moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps" 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"/> <XMessage v-if="item.type === 'item'" :message="item.data"/>
<div v-else-if="item.type === 'date'" :class="$style.dateDivider"> <div v-else-if="item.type === 'date'" :class="$style.dateDivider">
<span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
</div> </div>
</template> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>

View file

@ -46,7 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="tag == null"> <template v-if="tag == null">
<MkFoldableSection class="_margin"> <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"/> <MkUserList :pagination="popularUsersF"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
@ -65,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { watch, ref, useTemplateRef, computed } from 'vue'; import { watch, ref, useTemplateRef, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { host } from '@@/js/config';
import MkUserList from '@/components/MkUserList.vue'; import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue'; import MkTab from '@/components/MkTab.vue';
@ -73,7 +78,7 @@ import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
tag?: string; tag?: string | undefined;
}>(); }>();
const origin = ref('local'); const origin = ref('local');
@ -86,43 +91,48 @@ watch(() => props.tag, () => {
}); });
const tagUsers = computed(() => ({ const tagUsers = computed(() => ({
endpoint: 'hashtags/users' as const, endpoint: 'hashtags/users',
limit: 30, limit: 30,
params: { params: {
tag: props.tag, tag: props.tag,
origin: 'combined', origin: 'combined',
sort: '+follower', 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: { const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive', state: 'alive',
origin: 'local', origin: 'local',
sort: '+follower', sort: '+follower',
} }; } } as const;
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local', origin: 'local',
sort: '+updatedAt', sort: '+updatedAt',
} }; } } as const;
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local', origin: 'local',
state: 'alive', state: 'alive',
sort: '+createdAt', sort: '+createdAt',
} }; } } as const;
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive', state: 'alive',
origin: 'remote', origin: 'remote',
sort: '+follower', 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: { const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined', origin: 'combined',
sort: '+updatedAt', sort: '+updatedAt',
} }; } } as const;
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined', origin: 'combined',
sort: '+createdAt', sort: '+createdAt',
} }; } } as const;
misskeyApi('hashtags/list', { misskeyApi('hashtags/list', {
sort: '+attachedLocalUsers', sort: '+attachedLocalUsers',

View file

@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, ref } from 'vue'; import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js'; 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 type { Paging } from '@/components/MkPagination.vue';
import DynamicNoteDetailed from '@/components/DynamicNoteDetailed.vue'; import DynamicNoteDetailed from '@/components/DynamicNoteDetailed.vue';
import MkNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
@ -151,7 +151,7 @@ function fetchNote() {
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor, message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
openOnRemote: { openOnRemote: {
type: 'lookup', type: 'lookup',
url: `https://${host}/notes/${props.noteId}`, url: `${config.url}/notes/${props.noteId}`,
}, },
}); });
} }

View file

@ -22,6 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo> <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo>
<MkInfo warn>{{ i18n.ts.wordMuteWarning }}</MkInfo>
<SearchMarker <SearchMarker
:label="i18n.ts.showMutedWord" :label="i18n.ts.showMutedWord"
:keywords="['show']" :keywords="['show']"
@ -44,6 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo> <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
<MkInfo warn>{{ i18n.ts.wordMuteWarning }}</MkInfo>
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
</div> </div>
</MkFolder> </MkFolder>

View file

@ -196,8 +196,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer> </MkPreferenceContainer>
</SearchMarker> </SearchMarker>
<SearchMarker :keywords="['pinned', 'list']"> <SearchMarker v-slot="slotProps" :keywords="['pinned', 'list']">
<MkFolder> <MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
<MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton> <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> </MkPreferenceContainer>
</SearchMarker> </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']"> <SearchMarker :keywords="['reaction', 'count', 'show']">
<MkPreferenceContainer k="showReactionsCount"> <MkPreferenceContainer k="showReactionsCount">
<MkSwitch v-model="showReactionsCount"> <MkSwitch v-model="showReactionsCount">
@ -428,9 +436,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</SearchMarker> </SearchMarker>
</div> </div>
<SearchMarker :keywords="['default', 'note', 'visibility']"> <SearchMarker v-slot="slotProps" :keywords="['default', 'note', 'visibility']">
<MkDisableSection :disabled="rememberNoteVisibility"> <MkDisableSection :disabled="rememberNoteVisibility">
<MkFolder> <MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template>
<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</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> </MkPreferenceContainer>
</SearchMarker> </SearchMarker>
<SearchMarker :keywords="['boost', 'show', 'visib', 'selector']"> <SearchMarker v-slot="slotProps" :keywords="['boost', 'show', 'visib', 'selector']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget"> <MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.boostSettings }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts.boostSettings }}</SearchLabel></template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkPreferenceContainer k="showVisibilitySelectorOnBoost"> <SearchMarker :keywords="['boost', 'show', 'visib', 'selector']">
<MkSwitch v-model="showVisibilitySelectorOnBoost"> <MkPreferenceContainer k="showVisibilitySelectorOnBoost">
<template #label><SearchLabel>{{ i18n.ts.showVisibilitySelectorOnBoost }}</SearchLabel></template> <MkSwitch v-model="showVisibilitySelectorOnBoost">
<template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template> <template #label><SearchLabel>{{ i18n.ts.showVisibilitySelectorOnBoost }}</SearchLabel></template>
</MkSwitch> <template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template>
</MkPreferenceContainer> </MkSwitch>
<MkPreferenceContainer k="visibilityOnBoost"> </MkPreferenceContainer>
<MkSelect v-model="visibilityOnBoost"> </SearchMarker>
<template #label><SearchLabel>{{ i18n.ts.visibilityOnBoost }}</SearchLabel></template> <SearchMarker :keywords="['boost', 'visib']">
<option value="public">{{ i18n.ts._visibility['public'] }}</option> <MkPreferenceContainer k="visibilityOnBoost">
<option value="home">{{ i18n.ts._visibility['home'] }}</option> <MkSelect v-model="visibilityOnBoost">
<option value="followers">{{ i18n.ts._visibility['followers'] }}</option> <template #label><SearchLabel>{{ i18n.ts.visibilityOnBoost }}</SearchLabel></template>
</MkSelect> <option value="public">{{ i18n.ts._visibility['public'] }}</option>
</MkPreferenceContainer> <option value="home">{{ i18n.ts._visibility['home'] }}</option>
<option value="followers">{{ i18n.ts._visibility['followers'] }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
</div> </div>
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
@ -900,8 +912,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer> </MkPreferenceContainer>
</SearchMarker> </SearchMarker>
<SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']"> <SearchMarker v-slot="slotProps" :keywords="['emoji', 'dictionary', 'additional', 'extra']">
<MkFolder> <MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template>
<div class="_buttons"> <div class="_buttons">
<template v-for="lang in emojiIndexLangs" :key="lang"> <template v-for="lang in emojiIndexLangs" :key="lang">
@ -973,6 +985,7 @@ const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior');
const hemisphere = prefer.model('hemisphere'); const hemisphere = prefer.model('hemisphere');
const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover'); const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover');
const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter'); const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter');
const showTranslationButtonInNoteFooter = prefer.model('showTranslationButtonInNoteFooter');
const collapseRenotes = prefer.model('collapseRenotes'); const collapseRenotes = prefer.model('collapseRenotes');
const advancedMfm = prefer.model('advancedMfm'); const advancedMfm = prefer.model('advancedMfm');
const showReactionsCount = prefer.model('showReactionsCount'); const showReactionsCount = prefer.model('showReactionsCount');
@ -1109,6 +1122,7 @@ watch([
makeEveryTextElementsSelectable, makeEveryTextElementsSelectable,
enableHorizontalSwipe, enableHorizontalSwipe,
enablePullToRefresh, enablePullToRefresh,
noteDesign,
], async () => { ], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
}); });

Some files were not shown because too many files have changed in this diff Show more