Merge branch Sharkey:develop into trackeropt
This commit is contained in:
commit
249fe253a0
76 changed files with 1789 additions and 577 deletions
|
|
@ -321,9 +321,24 @@ attachLdSignatureForRelays: true
|
|||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
# Some example configurations:
|
||||
#allowedPrivateNetworks:
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']
|
||||
|
||||
|
|
|
|||
|
|
@ -269,9 +269,27 @@ proxyRemoteFiles: true
|
|||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
# Some example configurations:
|
||||
allowedPrivateNetworks:
|
||||
# Allow connections to 127.0.0.1 on any port
|
||||
- '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
# Disable automatic redirect for ActivityPub object lookup. (default: false)
|
||||
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
|
||||
|
|
|
|||
|
|
@ -378,9 +378,24 @@ attachLdSignatureForRelays: true
|
|||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
# Some example configurations:
|
||||
#allowedPrivateNetworks:
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']
|
||||
|
||||
|
|
|
|||
|
|
@ -381,9 +381,24 @@ attachLdSignatureForRelays: true
|
|||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
# Some example configurations:
|
||||
#allowedPrivateNetworks:
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1/32'
|
||||
# # Allow connections to 127.0.0.* on any port
|
||||
# - '127.0.0.1/24'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on any port
|
||||
# - network: '127.0.0.1'
|
||||
# # Allow connections to 127.0.0.1 on port 80
|
||||
# - network: '127.0.0.1'
|
||||
# ports: [80]
|
||||
# # Allow connections to 127.0.0.1 on port 80 or 443
|
||||
# - network: '127.0.0.1'
|
||||
# ports:
|
||||
# - 80
|
||||
# - 443
|
||||
|
||||
#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks']
|
||||
|
||||
|
|
|
|||
110
locales/index.d.ts
vendored
110
locales/index.d.ts
vendored
|
|
@ -7599,6 +7599,10 @@ export interface Locale extends ILocale {
|
|||
* Maximum number of scheduled notes
|
||||
*/
|
||||
"scheduleNoteMax": string;
|
||||
/**
|
||||
* Can appear in trending notes / users
|
||||
*/
|
||||
"canTrend": string;
|
||||
};
|
||||
"_condition": {
|
||||
/**
|
||||
|
|
@ -7677,7 +7681,59 @@ export interface Locale extends ILocale {
|
|||
* ~ではない
|
||||
*/
|
||||
"not": string;
|
||||
/**
|
||||
* Is from a specific instance
|
||||
*/
|
||||
"isFromInstance": string;
|
||||
/**
|
||||
* Hostname (case-insensitive)
|
||||
*/
|
||||
"isFromInstanceHost": string;
|
||||
/**
|
||||
* Match subdomains
|
||||
*/
|
||||
"isFromInstanceSubdomains": string;
|
||||
/**
|
||||
* User is from a bubble instance
|
||||
*/
|
||||
"fromBubbleInstance": string;
|
||||
/**
|
||||
* Has X or fewer local followers
|
||||
*/
|
||||
"localFollowersLessThanOrEq": string;
|
||||
/**
|
||||
* Has X or more local followers
|
||||
*/
|
||||
"localFollowersMoreThanOrEq": string;
|
||||
/**
|
||||
* Follows X or fewer local accounts
|
||||
*/
|
||||
"localFollowingLessThanOrEq": string;
|
||||
/**
|
||||
* Follows X or more local accounts
|
||||
*/
|
||||
"localFollowingMoreThanOrEq": string;
|
||||
/**
|
||||
* Has X or fewer remote followers
|
||||
*/
|
||||
"remoteFollowersLessThanOrEq": string;
|
||||
/**
|
||||
* Has X or more remote followers
|
||||
*/
|
||||
"remoteFollowersMoreThanOrEq": string;
|
||||
/**
|
||||
* Follows X or fewer remote accounts
|
||||
*/
|
||||
"remoteFollowingLessThanOrEq": string;
|
||||
/**
|
||||
* Follows X or more remote accounts
|
||||
*/
|
||||
"remoteFollowingMoreThanOrEq": string;
|
||||
};
|
||||
/**
|
||||
* This condition may be incorrect for remote users.
|
||||
*/
|
||||
"remoteDataWarning": string;
|
||||
};
|
||||
"_sensitiveMediaDetection": {
|
||||
/**
|
||||
|
|
@ -12949,7 +13005,7 @@ export interface Locale extends ILocale {
|
|||
"enableProxyAccountDescription": string;
|
||||
"_confirmPollEdit": {
|
||||
/**
|
||||
* Are you sure you want to edit this poll?
|
||||
* Are you sure you want to edit this poll
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
|
|
@ -12957,6 +13013,58 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"text": string;
|
||||
};
|
||||
/**
|
||||
* Test patterns
|
||||
*/
|
||||
"wordMuteTestLabel": string;
|
||||
/**
|
||||
* Enter some text here to test your word patterns. The matched words, if any, will be displayed below.
|
||||
*/
|
||||
"wordMuteTestDescription": string;
|
||||
/**
|
||||
* Test
|
||||
*/
|
||||
"wordMuteTestTest": string;
|
||||
/**
|
||||
* Matched words: {words}
|
||||
*/
|
||||
"wordMuteTestMatch": ParameterizedString<"words">;
|
||||
/**
|
||||
* No results yet, enter some text and click "Test" to check it.
|
||||
*/
|
||||
"wordMuteTestNoResults": string;
|
||||
/**
|
||||
* Text does not match any patterns.
|
||||
*/
|
||||
"wordMuteTestNoMatch": string;
|
||||
/**
|
||||
* Bubble timeline
|
||||
*/
|
||||
"bubbleTimeline": string;
|
||||
/**
|
||||
* Choose which instances should be displayed in the bubble.
|
||||
*/
|
||||
"bubbleTimelineDescription": string;
|
||||
/**
|
||||
* Note: the bubble timeline is hidden by default, and must be enabled via roles.
|
||||
*/
|
||||
"bubbleTimelineMustBeEnabled": string;
|
||||
/**
|
||||
* Users popular on the global network
|
||||
*/
|
||||
"popularUsersGlobal": string;
|
||||
/**
|
||||
* Users popular on {name}
|
||||
*/
|
||||
"popularUsersLocal": ParameterizedString<"name">;
|
||||
/**
|
||||
* Translation timeout
|
||||
*/
|
||||
"translationTimeoutLabel": string;
|
||||
/**
|
||||
* Timeout in milliseconds for translation API requests.
|
||||
*/
|
||||
"translationTimeoutCaption": string;
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
export class IndexUserNullDistinct1746813431756 {
|
||||
name = 'Indexusernulldistinct1746813431756'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5deb01ae162d1d70b80d064c27"`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5deb01ae162d1d70b80d064c27" ON "user" ("usernameLower", "host") NULLS NOT DISTINCT`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5deb01ae162d1d70b80d064c27"`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5deb01ae162d1d70b80d064c27" ON "user" ("usernameLower", "host") `);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@ import { fileURLToPath } from 'node:url';
|
|||
import { dirname, resolve } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { globSync } from 'glob';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import type { IPv4, IPv6 } from 'ipaddr.js';
|
||||
|
||||
type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
host?: string;
|
||||
|
|
@ -82,7 +84,7 @@ type Source = {
|
|||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
allowedPrivateNetworks?: PrivateNetworkSource[];
|
||||
disallowExternalApRedirect?: boolean;
|
||||
|
||||
maxFileSize?: number;
|
||||
|
|
@ -152,6 +154,60 @@ type Source = {
|
|||
}
|
||||
};
|
||||
|
||||
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
||||
|
||||
export type PrivateNetwork = {
|
||||
/**
|
||||
* CIDR IP/netmask definition of the IP range to match.
|
||||
*/
|
||||
cidr: CIDR;
|
||||
|
||||
/**
|
||||
* List of ports to match.
|
||||
* If undefined, then all ports match.
|
||||
* If empty, then NO ports match.
|
||||
*/
|
||||
ports?: number[];
|
||||
};
|
||||
|
||||
export type CIDR = [ip: IPv4 | IPv6, prefixLength: number];
|
||||
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[]): PrivateNetwork[];
|
||||
export function parsePrivateNetworks(patterns: undefined): undefined;
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined;
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined {
|
||||
if (!patterns) return undefined;
|
||||
return patterns
|
||||
.map(e => {
|
||||
if (typeof(e) === 'string') {
|
||||
const cidr = parseIpOrMask(e);
|
||||
if (cidr) {
|
||||
return { cidr } satisfies PrivateNetwork;
|
||||
}
|
||||
} else if (e.network) {
|
||||
const cidr = parseIpOrMask(e.network);
|
||||
if (cidr) {
|
||||
return { cidr, ports: e.ports } satisfies PrivateNetwork;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[config] Skipping invalid entry in allowedPrivateNetworks: ', e);
|
||||
return null;
|
||||
})
|
||||
.filter(p => p != null);
|
||||
}
|
||||
|
||||
function parseIpOrMask(ipOrMask: string): CIDR | null {
|
||||
if (ipaddr.isValidCIDR(ipOrMask)) {
|
||||
return ipaddr.parseCIDR(ipOrMask);
|
||||
}
|
||||
if (ipaddr.isValid(ipOrMask)) {
|
||||
const ip = ipaddr.parse(ipOrMask);
|
||||
return [ip, 32];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
url: string;
|
||||
port: number;
|
||||
|
|
@ -190,7 +246,7 @@ export type Config = {
|
|||
proxy: string | undefined;
|
||||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
allowedPrivateNetworks: PrivateNetwork[] | undefined;
|
||||
disallowExternalApRedirect: boolean;
|
||||
maxFileSize: number;
|
||||
maxNoteLength: number;
|
||||
|
|
@ -382,7 +438,7 @@ export function loadConfig(): Config {
|
|||
proxy: config.proxy,
|
||||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks),
|
||||
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
maxNoteLength: config.maxNoteLength ?? 3000,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -15,6 +15,24 @@ import { bindThis } from '@/decorators.js';
|
|||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export interface FollowStats {
|
||||
localFollowing: number;
|
||||
localFollowers: number;
|
||||
remoteFollowing: number;
|
||||
remoteFollowers: number;
|
||||
}
|
||||
|
||||
export interface CachedTranslation {
|
||||
sourceLang: string | undefined;
|
||||
text: string | undefined;
|
||||
}
|
||||
|
||||
interface CachedTranslationEntity {
|
||||
l?: string;
|
||||
t?: string;
|
||||
u?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: MemoryKVCache<MiUser>;
|
||||
|
|
@ -27,6 +45,8 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
||||
private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
||||
private readonly translationsCache: RedisKVCache<CachedTranslationEntity>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
|
@ -116,6 +136,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
|
||||
this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
|
||||
lifetime: 1000 * 60 * 60 * 24 * 7, // 1 week,
|
||||
memoryCacheLifetime: 1000 * 60, // 1 minute
|
||||
});
|
||||
|
||||
// NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
|
|
@ -167,6 +192,18 @@ export class CacheService implements OnApplicationShutdown {
|
|||
const followee = this.userByIdCache.get(body.followeeId);
|
||||
if (followee) followee.followersCount++;
|
||||
this.userFollowingsCache.delete(body.followerId);
|
||||
this.userFollowStatsCache.delete(body.followerId);
|
||||
this.userFollowStatsCache.delete(body.followeeId);
|
||||
break;
|
||||
}
|
||||
case 'unfollow': {
|
||||
const follower = this.userByIdCache.get(body.followerId);
|
||||
if (follower) follower.followingCount--;
|
||||
const followee = this.userByIdCache.get(body.followeeId);
|
||||
if (followee) followee.followersCount--;
|
||||
this.userFollowingsCache.delete(body.followerId);
|
||||
this.userFollowStatsCache.delete(body.followerId);
|
||||
this.userFollowStatsCache.delete(body.followeeId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
@ -187,6 +224,80 @@ export class CacheService implements OnApplicationShutdown {
|
|||
}) ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
|
||||
return await this.userFollowStatsCache.fetch(userId, async () => {
|
||||
const stats = {
|
||||
localFollowing: 0,
|
||||
localFollowers: 0,
|
||||
remoteFollowing: 0,
|
||||
remoteFollowers: 0,
|
||||
};
|
||||
|
||||
const followings = await this.followingsRepository.findBy([
|
||||
{ followerId: userId },
|
||||
{ followeeId: userId },
|
||||
]);
|
||||
|
||||
for (const following of followings) {
|
||||
if (following.followerId === userId) {
|
||||
// increment following; user is a follower of someone else
|
||||
if (following.followeeHost == null) {
|
||||
stats.localFollowing++;
|
||||
} else {
|
||||
stats.remoteFollowing++;
|
||||
}
|
||||
} else if (following.followeeId === userId) {
|
||||
// increment followers; user is followed by someone else
|
||||
if (following.followerHost == null) {
|
||||
stats.localFollowers++;
|
||||
} else {
|
||||
stats.remoteFollowers++;
|
||||
}
|
||||
} else {
|
||||
// Should never happen
|
||||
}
|
||||
}
|
||||
|
||||
// Infer remote-remote followers heuristically, since we don't track that info directly.
|
||||
const user = await this.findUserById(userId);
|
||||
if (user.host !== null) {
|
||||
stats.remoteFollowing = Math.max(0, user.followingCount - stats.localFollowing);
|
||||
stats.remoteFollowers = Math.max(0, user.followersCount - stats.localFollowers);
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getCachedTranslation(note: MiNote, targetLang: string): Promise<CachedTranslation | null> {
|
||||
const cacheKey = `${note.id}@${targetLang}`;
|
||||
|
||||
// Use cached translation, if present and up-to-date
|
||||
const cached = await this.translationsCache.get(cacheKey);
|
||||
if (cached && cached.u === note.updatedAt?.valueOf()) {
|
||||
return {
|
||||
sourceLang: cached.l,
|
||||
text: cached.t,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache entry :(
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async setCachedTranslation(note: MiNote, targetLang: string, translation: CachedTranslation): Promise<void> {
|
||||
const cacheKey = `${note.id}@${targetLang}`;
|
||||
|
||||
await this.translationsCache.set(cacheKey, {
|
||||
l: translation.sourceLang,
|
||||
t: translation.text,
|
||||
u: note.updatedAt?.valueOf(),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
|
|
|
|||
|
|
@ -63,8 +63,6 @@ export class DeleteAccountService {
|
|||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
|
|
@ -73,22 +71,17 @@ export class DeleteAccountService {
|
|||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
const inboxes = followings.map(x => [x.followerSharedInbox ?? x.followeeSharedInbox as string, true] as const);
|
||||
const queue = new Map<string, true>(inboxes);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
await this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
} else {
|
||||
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
await this.queueService.createDeleteAccountJob(user, {
|
||||
soft: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as Redis from 'ioredis';
|
|||
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
|
|
@ -21,6 +22,8 @@ export class FeaturedService {
|
|||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +34,14 @@ export class FeaturedService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score: number, userId: string | null): Promise<void> {
|
||||
if (userId) {
|
||||
const policies = await this.roleService.getUserPolicies(userId);
|
||||
if (!policies.canTrend) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const redisTransaction = this.redisClient.multi();
|
||||
redisTransaction.zincrby(
|
||||
|
|
@ -89,28 +99,28 @@ export class FeaturedService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updateGlobalNotesRanking(note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score);
|
||||
public updateGalleryPostsRanking(galleryPost: Pick<MiGalleryPost, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick<MiNote, 'id'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score, null);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import fetch from 'node-fetch';
|
|||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Config, PrivateNetwork } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
|
|
@ -20,12 +20,36 @@ import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
|||
import { ApUtilityService } from './activitypub/ApUtilityService.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export type HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: boolean;
|
||||
validators?: ((res: Response) => void)[];
|
||||
};
|
||||
|
||||
export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(cidr)) {
|
||||
if (ports == null || (port != null && ports.includes(port))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
|
||||
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
|
||||
const address = socket.remoteAddress;
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'node:http' {
|
||||
interface Agent {
|
||||
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||
|
|
@ -44,31 +68,12 @@ class HttpRequestServiceAgent extends http.Agent {
|
|||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
validateSocketConnect(this.config.allowedPrivateNetworks, socket);
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
class HttpsRequestServiceAgent extends https.Agent {
|
||||
|
|
@ -83,31 +88,12 @@ class HttpsRequestServiceAgent extends https.Agent {
|
|||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
validateSocketConnect(this.config.allowedPrivateNetworks, socket);
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
|
|||
|
|
@ -592,6 +592,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
if (!this.isRenote(note) || this.isQuote(note)) {
|
||||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
} else {
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
this.pushToTl(note, user);
|
||||
|
|
@ -631,7 +633,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) {
|
||||
this.incRenoteCount(data.renote);
|
||||
this.incRenoteCount(data.renote, user);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
|
|
@ -814,8 +816,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private incRenoteCount(renote: MiNote) {
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
private async incRenoteCount(renote: MiNote, user: MiUser) {
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
})
|
||||
|
|
@ -823,15 +825,18 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
.execute();
|
||||
|
||||
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote.id, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
|
||||
if (user.isExplorable && Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
const policies = await this.roleService.getUserPolicies(user);
|
||||
if (policies.canTrend) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,9 +124,11 @@ export class NoteDeleteService {
|
|||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
if (!isRenote(note) || isQuote(note)) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
} else {
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
|
|
@ -165,8 +167,11 @@ export class NoteDeleteService {
|
|||
});
|
||||
}
|
||||
|
||||
if (note.uri) {
|
||||
this.apLogService.deleteObjectLogs(note.uri)
|
||||
const deletedUris = [note, ...cascadingNotes]
|
||||
.map(n => n.uri)
|
||||
.filter((u): u is string => u != null);
|
||||
if (deletedUris.length > 0) {
|
||||
this.apLogService.deleteObjectLogs(deletedUris)
|
||||
.catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
// ハッシュタグ更新
|
||||
this.pushToTl(note, user);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
|||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
|
@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
|||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class NotePiningService {
|
||||
|
|
@ -34,6 +35,9 @@ export class NotePiningService {
|
|||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private readonly db: DataSource,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private roleService: RoleService,
|
||||
|
|
@ -60,21 +64,23 @@ export class NotePiningService {
|
|||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
||||
}
|
||||
|
||||
const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
|
||||
await this.db.transaction(async tem => {
|
||||
const pinings = await tem.findBy(MiUserNotePining, { userId: user.id });
|
||||
|
||||
if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) {
|
||||
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
||||
}
|
||||
if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) {
|
||||
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
||||
}
|
||||
|
||||
if (pinings.some(pining => pining.noteId === note.id)) {
|
||||
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
||||
}
|
||||
if (pinings.some(pining => pining.noteId === note.id)) {
|
||||
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
||||
}
|
||||
|
||||
await this.userNotePiningsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
} as MiUserNotePining);
|
||||
await tem.insert(MiUserNotePining, {
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
|
|||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
const FALLBACK = '\u2764';
|
||||
|
||||
|
|
@ -102,6 +103,7 @@ export class ReactionService {
|
|||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private notificationService: NotificationService,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -212,20 +214,28 @@ export class ReactionService {
|
|||
.execute();
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (
|
||||
Math.random() < 0.3 &&
|
||||
note.userId !== user.id &&
|
||||
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
|
||||
) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note.id, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
|
||||
const author = await this.cacheService.findUserById(note.userId);
|
||||
if (author.isExplorable) {
|
||||
const policies = await this.roleService.getUserPolicies(author);
|
||||
if (policies.canTrend) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -298,9 +308,9 @@ export class ReactionService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) {
|
||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) {
|
||||
// if already unreacted
|
||||
const exist = await this.noteReactionsRepository.findOneBy({
|
||||
exist ??= await this.noteReactionsRepository.findOneBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
|
@ -330,6 +340,8 @@ export class ReactionService {
|
|||
.execute();
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type { MiUser } from '@/models/User.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { FollowStats } from '@/core/CacheService.js';
|
||||
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
|
|
@ -68,6 +69,7 @@ export type RolePolicies = {
|
|||
canImportMuting: boolean;
|
||||
canImportUserLists: boolean;
|
||||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
canTrend: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICIES: RolePolicies = {
|
||||
|
|
@ -92,7 +94,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canUpdateBioMedia: true,
|
||||
pinLimit: 5,
|
||||
antennaLimit: 5,
|
||||
wordMuteLimit: 200,
|
||||
wordMuteLimit: 1000,
|
||||
webhookLimit: 3,
|
||||
clipLimit: 10,
|
||||
noteEachClipsLimit: 200,
|
||||
|
|
@ -107,6 +109,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canImportMuting: true,
|
||||
canImportUserLists: true,
|
||||
chatAvailability: 'available',
|
||||
canTrend: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -148,6 +151,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
) {
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
|
||||
// TODO additional cache for final calculation?
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
|
@ -221,20 +225,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
|
||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue, followStats: FollowStats): boolean {
|
||||
try {
|
||||
switch (value.type) {
|
||||
// ~かつ~
|
||||
case 'and': {
|
||||
return value.values.every(v => this.evalCond(user, roles, v));
|
||||
return value.values.every(v => this.evalCond(user, roles, v, followStats));
|
||||
}
|
||||
// ~または~
|
||||
case 'or': {
|
||||
return value.values.some(v => this.evalCond(user, roles, v));
|
||||
return value.values.some(v => this.evalCond(user, roles, v, followStats));
|
||||
}
|
||||
// ~ではない
|
||||
case 'not': {
|
||||
return !this.evalCond(user, roles, value.value);
|
||||
return !this.evalCond(user, roles, value.value, followStats);
|
||||
}
|
||||
// マニュアルロールがアサインされている
|
||||
case 'roleAssignedTo': {
|
||||
|
|
@ -248,6 +252,23 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
case 'isRemote': {
|
||||
return this.userEntityService.isRemoteUser(user);
|
||||
}
|
||||
// User is from a specific instance
|
||||
case 'isFromInstance': {
|
||||
if (user.host == null) {
|
||||
return false;
|
||||
}
|
||||
if (value.subdomains) {
|
||||
const userHost = '.' + user.host.toLowerCase();
|
||||
const targetHost = '.' + value.host.toLowerCase();
|
||||
return userHost.endsWith(targetHost);
|
||||
} else {
|
||||
return user.host.toLowerCase() === value.host.toLowerCase();
|
||||
}
|
||||
}
|
||||
// Is the user from a local bubble instance
|
||||
case 'fromBubbleInstance': {
|
||||
return user.host != null && this.meta.bubbleInstances.includes(user.host);
|
||||
}
|
||||
// サスペンド済みユーザである
|
||||
case 'isSuspended': {
|
||||
return user.isSuspended;
|
||||
|
|
@ -292,6 +313,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
case 'followingMoreThanOrEq': {
|
||||
return user.followingCount >= value.value;
|
||||
}
|
||||
case 'localFollowersLessThanOrEq': {
|
||||
return followStats.localFollowers <= value.value;
|
||||
}
|
||||
case 'localFollowersMoreThanOrEq': {
|
||||
return followStats.localFollowers >= value.value;
|
||||
}
|
||||
case 'localFollowingLessThanOrEq': {
|
||||
return followStats.localFollowing <= value.value;
|
||||
}
|
||||
case 'localFollowingMoreThanOrEq': {
|
||||
return followStats.localFollowing >= value.value;
|
||||
}
|
||||
case 'remoteFollowersLessThanOrEq': {
|
||||
return followStats.remoteFollowers <= value.value;
|
||||
}
|
||||
case 'remoteFollowersMoreThanOrEq': {
|
||||
return followStats.remoteFollowers >= value.value;
|
||||
}
|
||||
case 'remoteFollowingLessThanOrEq': {
|
||||
return followStats.remoteFollowing <= value.value;
|
||||
}
|
||||
case 'remoteFollowingMoreThanOrEq': {
|
||||
return followStats.remoteFollowing >= value.value;
|
||||
}
|
||||
// ノート数が指定値以下
|
||||
case 'notesLessThanOrEq': {
|
||||
return user.notesCount <= value.value;
|
||||
|
|
@ -316,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserAssigns(userId: MiUser['id']) {
|
||||
public async getUserAssigns(userOrId: MiUser | MiUser['id']) {
|
||||
const now = Date.now();
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
|
|
@ -325,12 +371,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserRoles(userId: MiUser['id']) {
|
||||
public async getUserRoles(userOrId: MiUser | MiUser['id']) {
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const assigns = await this.getUserAssigns(userId);
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
const followStats = await this.cacheService.getFollowStats(userId);
|
||||
const assigns = await this.getUserAssigns(userOrId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
}
|
||||
|
||||
|
|
@ -338,18 +386,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
* 指定ユーザーのバッジロール一覧取得
|
||||
*/
|
||||
@bindThis
|
||||
public async getUserBadgeRoles(userId: MiUser['id']) {
|
||||
public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) {
|
||||
const now = Date.now();
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const followStats = await this.cacheService.getFollowStats(userId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||
if (badgeCondRoles.length > 0) {
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||
} else {
|
||||
return assignedBadgeRoles;
|
||||
|
|
@ -357,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserPolicies(userId: MiUser['id'] | null): Promise<RolePolicies> {
|
||||
public async getUserPolicies(userOrId: MiUser | MiUser['id'] | null): Promise<RolePolicies> {
|
||||
const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies };
|
||||
|
||||
if (userId == null) return basePolicies;
|
||||
if (userOrId == null) return basePolicies;
|
||||
|
||||
const roles = await this.getUserRoles(userId);
|
||||
const roles = await this.getUserRoles(userOrId);
|
||||
|
||||
function calc<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) {
|
||||
if (roles.length === 0) return basePolicies[name];
|
||||
|
|
@ -421,6 +471,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
|
||||
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
|
||||
chatAvailability: calc('chatAvailability', aggregateChatAvailability),
|
||||
canTrend: calc('canTrend', vs => vs.some(v => v === true)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ export class RedisKVCache<T> {
|
|||
opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
|
||||
fetcher?: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter?: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter?: RedisKVCache<T>['fromRedisConverter'];
|
||||
},
|
||||
) {
|
||||
this.lifetime = opts.lifetime;
|
||||
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
this.toRedisConverter = opts.toRedisConverter;
|
||||
this.fromRedisConverter = opts.fromRedisConverter;
|
||||
this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); });
|
||||
this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value));
|
||||
this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -382,6 +382,12 @@ export class MiMeta {
|
|||
})
|
||||
public swPrivateKey: string | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 5000,
|
||||
comment: 'Timeout in milliseconds for translation API requests',
|
||||
})
|
||||
public translationTimeout: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
|
|
|
|||
|
|
@ -264,3 +264,7 @@ export type IMentionedRemoteUsers = {
|
|||
username: string;
|
||||
host: string;
|
||||
}[];
|
||||
|
||||
export function hasText(note: MiNote): note is MiNote & { text: string } {
|
||||
return note.text != null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,22 @@ type CondFormulaValueIsRemote = {
|
|||
type: 'isRemote';
|
||||
};
|
||||
|
||||
/**
|
||||
* User is from a specific instance
|
||||
*/
|
||||
type CondFormulaValueIsFromInstance = {
|
||||
type: 'isFromInstance';
|
||||
host: string;
|
||||
subdomains: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is the user from a local bubble instance
|
||||
*/
|
||||
type CondFormulaValueFromBubbleInstance = {
|
||||
type: 'fromBubbleInstance';
|
||||
};
|
||||
|
||||
/**
|
||||
* 既に指定のマニュアルロールにアサインされている場合のみ成立とする
|
||||
*/
|
||||
|
|
@ -138,6 +154,70 @@ type CondFormulaValueFollowingMoreThanOrEq = {
|
|||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is followed by at most N local users
|
||||
*/
|
||||
type CondFormulaValueLocalFollowersLessThanOrEq = {
|
||||
type: 'localFollowersLessThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is followed by at least N local users
|
||||
*/
|
||||
type CondFormulaValueLocalFollowersMoreThanOrEq = {
|
||||
type: 'localFollowersMoreThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is following at most N local users
|
||||
*/
|
||||
type CondFormulaValueLocalFollowingLessThanOrEq = {
|
||||
type: 'localFollowingLessThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is following at least N local users
|
||||
*/
|
||||
type CondFormulaValueLocalFollowingMoreThanOrEq = {
|
||||
type: 'localFollowingMoreThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is followed by at most N remote users
|
||||
*/
|
||||
type CondFormulaValueRemoteFollowersLessThanOrEq = {
|
||||
type: 'remoteFollowersLessThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is followed by at least N remote users
|
||||
*/
|
||||
type CondFormulaValueRemoteFollowersMoreThanOrEq = {
|
||||
type: 'remoteFollowersMoreThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is following at most N remote users
|
||||
*/
|
||||
type CondFormulaValueRemoteFollowingLessThanOrEq = {
|
||||
type: 'remoteFollowingLessThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is following at least N remote users
|
||||
*/
|
||||
type CondFormulaValueRemoteFollowingMoreThanOrEq = {
|
||||
type: 'remoteFollowingMoreThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 投稿数が指定値以下の場合のみ成立とする
|
||||
*/
|
||||
|
|
@ -160,6 +240,8 @@ export type RoleCondFormulaValue = { id: string } & (
|
|||
CondFormulaValueNot |
|
||||
CondFormulaValueIsLocal |
|
||||
CondFormulaValueIsRemote |
|
||||
CondFormulaValueIsFromInstance |
|
||||
CondFormulaValueFromBubbleInstance |
|
||||
CondFormulaValueIsSuspended |
|
||||
CondFormulaValueIsLocked |
|
||||
CondFormulaValueIsBot |
|
||||
|
|
@ -172,6 +254,14 @@ export type RoleCondFormulaValue = { id: string } & (
|
|||
CondFormulaValueFollowersMoreThanOrEq |
|
||||
CondFormulaValueFollowingLessThanOrEq |
|
||||
CondFormulaValueFollowingMoreThanOrEq |
|
||||
CondFormulaValueLocalFollowersLessThanOrEq |
|
||||
CondFormulaValueLocalFollowersMoreThanOrEq |
|
||||
CondFormulaValueLocalFollowingLessThanOrEq |
|
||||
CondFormulaValueLocalFollowingMoreThanOrEq |
|
||||
CondFormulaValueRemoteFollowersLessThanOrEq |
|
||||
CondFormulaValueRemoteFollowersMoreThanOrEq |
|
||||
CondFormulaValueRemoteFollowingLessThanOrEq |
|
||||
CondFormulaValueRemoteFollowingMoreThanOrEq |
|
||||
CondFormulaValueNotesLessThanOrEq |
|
||||
CondFormulaValueNotesMoreThanOrEq
|
||||
);
|
||||
|
|
|
|||
|
|
@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = {
|
|||
optional: false, nullable: false,
|
||||
enum: ['available', 'readonly', 'unavailable'],
|
||||
},
|
||||
canTrend: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MoreThan } from 'typeorm';
|
||||
import { In, MoreThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository, RegistryItemsRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
|
|
@ -17,10 +17,10 @@ import { bindThis } from '@/decorators.js';
|
|||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { ApLogService } from '@/core/ApLogService.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserDeleteJobData } from '../types.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountProcessorService {
|
||||
|
|
@ -45,6 +45,48 @@ export class DeleteAccountProcessorService {
|
|||
@Inject(DI.noteScheduleRepository)
|
||||
private noteScheduleRepository: NoteScheduleRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private readonly followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private readonly followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private readonly blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private readonly mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.clipsRepository)
|
||||
private readonly clipsRepository: ClipsRepository,
|
||||
|
||||
@Inject(DI.clipNotesRepository)
|
||||
private readonly clipNotesRepository: ClipNotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private readonly latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
@Inject(DI.noteEditRepository)
|
||||
private readonly noteEditRepository: NoteEditRepository,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private readonly noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
@Inject(DI.pollVotesRepository)
|
||||
private readonly pollVotesRepository: PollVotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private readonly pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.signinsRepository)
|
||||
private readonly signinsRepository: SigninsRepository,
|
||||
|
||||
@Inject(DI.userIpsRepository)
|
||||
private readonly userIpsRepository: UserIpsRepository,
|
||||
|
||||
@Inject(DI.registryItemsRepository)
|
||||
private readonly registryItemsRepository: RegistryItemsRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private driveService: DriveService,
|
||||
private emailService: EmailService,
|
||||
|
|
@ -65,6 +107,140 @@ export class DeleteAccountProcessorService {
|
|||
return;
|
||||
}
|
||||
|
||||
{ // Delete user clips
|
||||
const userClips = await this.clipsRepository.find({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
}) as { id: string }[];
|
||||
|
||||
// Delete one-at-a-time because there can be a lot
|
||||
for (const clip of userClips) {
|
||||
await this.clipNotesRepository.delete({
|
||||
id: clip.id,
|
||||
});
|
||||
}
|
||||
|
||||
await this.clipsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
this.logger.succ('All clips have been deleted.');
|
||||
}
|
||||
|
||||
{ // Delete favorites
|
||||
await this.noteFavoritesRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
this.logger.succ('All favorites have been deleted.');
|
||||
}
|
||||
|
||||
{ // Delete user relations
|
||||
await this.followingsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
|
||||
await this.followingsRepository.delete({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
await this.blockingsRepository.delete({
|
||||
blockerId: user.id,
|
||||
});
|
||||
|
||||
await this.blockingsRepository.delete({
|
||||
blockeeId: user.id,
|
||||
});
|
||||
|
||||
await this.mutingsRepository.delete({
|
||||
muterId: user.id,
|
||||
});
|
||||
|
||||
await this.mutingsRepository.delete({
|
||||
muteeId: user.id,
|
||||
});
|
||||
|
||||
this.logger.succ('All user relations have been deleted.');
|
||||
}
|
||||
|
||||
{ // Delete reactions
|
||||
let cursor: MiNoteReaction['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const reactions = await this.noteReactionsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
relations: {
|
||||
note: true,
|
||||
},
|
||||
}) as MiNoteReaction[];
|
||||
|
||||
if (reactions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = reactions.at(-1)?.id ?? null;
|
||||
|
||||
for (const reaction of reactions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const note = reaction.note!;
|
||||
await this.reactionService.delete(user, note, reaction);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ('All reactions have been deleted');
|
||||
}
|
||||
|
||||
{ // Poll votes
|
||||
let cursor: MiNoteReaction['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const votes = await this.pollVotesRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
}) as { id: string }[];
|
||||
|
||||
if (votes.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = votes.at(-1)?.id ?? null;
|
||||
|
||||
await this.pollVotesRepository.delete({
|
||||
id: In(votes.map(v => v.id)),
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.succ('All poll votes have been deleted');
|
||||
}
|
||||
|
||||
{ // Delete scheduled notes
|
||||
const scheduledNotes = await this.noteScheduleRepository.findBy({
|
||||
userId: user.id,
|
||||
|
|
@ -82,6 +258,10 @@ export class DeleteAccountProcessorService {
|
|||
}
|
||||
|
||||
{ // Delete notes
|
||||
await this.latestNotesRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
let cursor: MiNote['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
|
|
@ -102,7 +282,23 @@ export class DeleteAccountProcessorService {
|
|||
|
||||
cursor = notes.at(-1)?.id ?? null;
|
||||
|
||||
await this.notesRepository.delete(notes.map(note => note.id));
|
||||
// Delete associated polls one-at-a-time, since it can cascade to a LOT of vote entries
|
||||
for (const note of notes) {
|
||||
if (note.hasPoll) {
|
||||
await this.pollsRepository.delete({
|
||||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ids = notes.map(note => note.id);
|
||||
|
||||
await this.noteEditRepository.delete({
|
||||
noteId: In(ids),
|
||||
});
|
||||
await this.notesRepository.delete({
|
||||
id: In(ids),
|
||||
});
|
||||
|
||||
for (const note of notes) {
|
||||
await this.searchService.unindexNote(note);
|
||||
|
|
@ -119,37 +315,6 @@ export class DeleteAccountProcessorService {
|
|||
this.logger.succ('All of notes deleted');
|
||||
}
|
||||
|
||||
{ // Delete reactions
|
||||
let cursor: MiNoteReaction['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const reactions = await this.noteReactionsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
}) as MiNoteReaction[];
|
||||
|
||||
if (reactions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = reactions.at(-1)?.id ?? null;
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote;
|
||||
|
||||
await this.reactionService.delete(user, note);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ('All reactions have been deleted');
|
||||
}
|
||||
|
||||
{ // Delete files
|
||||
let cursor: MiDriveFile['id'] | null = null;
|
||||
|
||||
|
|
@ -191,20 +356,42 @@ export class DeleteAccountProcessorService {
|
|||
this.logger.succ('All AP logs deleted');
|
||||
}
|
||||
|
||||
{ // Send email notification
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
if (profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, 'Account deleted',
|
||||
'Your account has been deleted.',
|
||||
'Your account has been deleted.');
|
||||
// Do this BEFORE deleting the account!
|
||||
const profile = await this.userProfilesRepository.findOneBy({ userId: user.id });
|
||||
|
||||
{ // Delete the actual account
|
||||
await this.userIpsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await this.signinsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await this.registryItemsRepository.delete({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// soft指定されている場合は物理削除しない
|
||||
if (job.data.soft) {
|
||||
// nop
|
||||
} else {
|
||||
await this.usersRepository.delete(user.id);
|
||||
}
|
||||
|
||||
this.logger.succ('Account data deleted');
|
||||
}
|
||||
|
||||
// soft指定されている場合は物理削除しない
|
||||
if (job.data.soft) {
|
||||
// nop
|
||||
} else {
|
||||
await this.usersRepository.delete(job.data.user.id);
|
||||
{ // Send email notification
|
||||
if (profile && profile.email && profile.emailVerified) {
|
||||
try {
|
||||
await this.emailService.sendEmail(profile.email, 'Account deleted',
|
||||
'Your account has been deleted.',
|
||||
'Your account has been deleted.');
|
||||
} catch (e) {
|
||||
this.logger.warn('Failed to send account deletion message:', { e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Account deleted';
|
||||
|
|
|
|||
|
|
@ -195,6 +195,10 @@ export class FileServerService {
|
|||
reply.header('Content-Length', file.file.size);
|
||||
|
||||
if (!image) {
|
||||
if (file.file.size > 0) {
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
}
|
||||
|
||||
if (request.headers.range && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
|
|
@ -215,7 +219,6 @@ export class FileServerService {
|
|||
};
|
||||
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
reply.code(206);
|
||||
} else {
|
||||
|
|
@ -257,6 +260,10 @@ export class FileServerService {
|
|||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', filename));
|
||||
|
||||
if (file.file.size > 0) {
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
}
|
||||
|
||||
if (request.headers.range && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
|
|
@ -271,7 +278,6 @@ export class FileServerService {
|
|||
end,
|
||||
});
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
reply.code(206);
|
||||
return fileStream;
|
||||
|
|
@ -284,6 +290,10 @@ export class FileServerService {
|
|||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||
|
||||
if (file.file.size > 0) {
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
}
|
||||
|
||||
if (request.headers.range && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
|
|
@ -298,7 +308,6 @@ export class FileServerService {
|
|||
end,
|
||||
});
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
reply.code(206);
|
||||
return fileStream;
|
||||
|
|
@ -442,6 +451,10 @@ export class FileServerService {
|
|||
}
|
||||
|
||||
if (!image) {
|
||||
if (file.file && file.file.size > 0) {
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
}
|
||||
|
||||
if (request.headers.range && file.file && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
|
|
@ -462,7 +475,6 @@ export class FileServerService {
|
|||
};
|
||||
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
reply.code(206);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -445,6 +445,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
translationTimeout: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
deeplAuthKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
@ -723,6 +727,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
objectStorageUseProxy: instance.objectStorageUseProxy,
|
||||
objectStorageSetPublicRead: instance.objectStorageSetPublicRead,
|
||||
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
|
||||
translationTimeout: instance.translationTimeout,
|
||||
deeplAuthKey: instance.deeplAuthKey,
|
||||
deeplIsPro: instance.deeplIsPro,
|
||||
deeplFreeMode: instance.deeplFreeMode,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
translationTimeout: { type: 'number' },
|
||||
deeplAuthKey: { type: 'string', nullable: true },
|
||||
deeplIsPro: { type: 'boolean' },
|
||||
deeplFreeMode: { type: 'boolean' },
|
||||
|
|
@ -560,6 +561,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle;
|
||||
}
|
||||
|
||||
if (ps.translationTimeout !== undefined) {
|
||||
set.translationTimeout = ps.translationTimeout;
|
||||
}
|
||||
|
||||
if (ps.deeplAuthKey !== undefined) {
|
||||
if (ps.deeplAuthKey === '') {
|
||||
set.deeplAuthKey = null;
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// ランキング更新
|
||||
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
|
||||
await this.featuredService.updateGalleryPostsRanking(post.id, 1);
|
||||
await this.featuredService.updateGalleryPostsRanking(post, 1);
|
||||
}
|
||||
|
||||
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// ランキング更新
|
||||
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
|
||||
await this.featuredService.updateGalleryPostsRanking(post.id, -1);
|
||||
await this.featuredService.updateGalleryPostsRanking(post, -1);
|
||||
}
|
||||
|
||||
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0');
|
||||
if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0');
|
||||
|
||||
// Ignore hidden hashtags
|
||||
query.andWhere(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM meta WHERE tag.name = ANY(meta."hiddenTags")
|
||||
)`);
|
||||
|
||||
switch (ps.sort) {
|
||||
case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break;
|
||||
case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js";
|
|||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: false,
|
||||
|
|
@ -41,6 +42,7 @@ export const paramDef = {
|
|||
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
|
||||
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
|
||||
trending: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['tag', 'sort'],
|
||||
} as const;
|
||||
|
|
@ -52,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
|
|
@ -80,7 +83,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break;
|
||||
}
|
||||
|
||||
const users = await query.limit(ps.limit).getMany();
|
||||
let users = await query.limit(ps.limit).getMany();
|
||||
|
||||
// This is not ideal, for a couple of reasons:
|
||||
// 1. It may return less than "limit" results.
|
||||
// 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
|
||||
// Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
|
||||
if (ps.trending) {
|
||||
const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
|
||||
users = usersWithRoles
|
||||
.filter(([,p]) => p.canTrend)
|
||||
.map(([u]) => u);
|
||||
}
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -330,8 +330,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
|
||||
|
||||
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
|
||||
// TODO: ちゃんと数える
|
||||
const length = JSON.stringify(mutedWords).length;
|
||||
const length = mutedWords.reduce((sum, word) => {
|
||||
const wordLength = Array.isArray(word)
|
||||
? word.reduce((l, w) => l + w.length, 0)
|
||||
: word.length;
|
||||
return sum + wordLength;
|
||||
}, 0);
|
||||
|
||||
if (length > limit) {
|
||||
throw new ApiError(meta.errors.tooManyMutedWords);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.andWhere('user.isExplorable = TRUE');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,22 +10,28 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import type { MiMeta, MiNote } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { hasText } from '@/models/Note.js';
|
||||
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
// TODO allow unauthenticated if default template allows?
|
||||
// Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role.
|
||||
// This will allow unauthenticated requests without leaking post data to restricted clients.
|
||||
requireCredential: true,
|
||||
kind: 'read:account',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
sourceLang: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
sourceLang: { type: 'string', optional: true, nullable: false },
|
||||
text: { type: 'string', optional: true, nullable: false },
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -45,6 +51,11 @@ export const meta = {
|
|||
code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE',
|
||||
id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d',
|
||||
},
|
||||
translationFailed: {
|
||||
message: 'Failed to translate note. Please try again later or contact an administrator for assistance.',
|
||||
code: 'TRANSLATION_FAILED',
|
||||
id: '4e7a1a4f-521c-4ba2-b10a-69e5e2987b2f',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
|
|
@ -73,6 +84,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private getterService: GetterService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private roleService: RoleService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly loggerService: ApiLoggerService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
|
@ -89,8 +102,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
|
||||
}
|
||||
|
||||
if (note.text == null) {
|
||||
return;
|
||||
if (!hasText(note)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance;
|
||||
|
|
@ -101,13 +114,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
let targetLang = ps.targetLang;
|
||||
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
|
||||
|
||||
let response = await this.cacheService.getCachedTranslation(note, targetLang);
|
||||
if (!response) {
|
||||
this.loggerService.logger.debug(`Fetching new translation for note=${note.id} lang=${targetLang}`);
|
||||
response = await this.fetchTranslation(note, targetLang);
|
||||
if (!response) {
|
||||
throw new ApiError(meta.errors.translationFailed);
|
||||
}
|
||||
|
||||
await this.cacheService.setCachedTranslation(note, targetLang, response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchTranslation(note: MiNote & { text: string }, targetLang: string) {
|
||||
// Load-bearing try/catch - removing this will shift indentation and cause ~80 lines of upstream merge conflicts
|
||||
try {
|
||||
// Ignore deeplFreeInstance unless deeplFreeMode is set
|
||||
const deeplFreeInstance = this.serverSettings.deeplFreeMode ? this.serverSettings.deeplFreeInstance : null;
|
||||
|
||||
// DeepL/DeepLX handling
|
||||
if (canDeepl) {
|
||||
if (this.serverSettings.deeplAuthKey || deeplFreeInstance) {
|
||||
const params = new URLSearchParams();
|
||||
if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
|
||||
params.append('text', note.text);
|
||||
params.append('target_lang', targetLang);
|
||||
const endpoint = canDeeplFree ? this.serverSettings.deeplFreeInstance as string : this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
const endpoint = deeplFreeInstance ?? this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
const res = await this.httpRequestService.send(endpoint, {
|
||||
method: 'POST',
|
||||
|
|
@ -116,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
Accept: 'application/json, */*',
|
||||
},
|
||||
body: params.toString(),
|
||||
timeout: this.serverSettings.translationTimeout,
|
||||
});
|
||||
if (this.serverSettings.deeplAuthKey) {
|
||||
const json = (await res.json()) as {
|
||||
|
|
@ -151,8 +185,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
// LibreTranslate handling
|
||||
if (canLibre) {
|
||||
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL as string, {
|
||||
if (this.serverSettings.libreTranslateURL) {
|
||||
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -165,6 +199,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
format: 'text',
|
||||
api_key: this.serverSettings.libreTranslateKey ?? '',
|
||||
}),
|
||||
timeout: this.serverSettings.translationTimeout,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
|
@ -182,8 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
text: json.translatedText,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
this.loggerService.logger.error('Unhandled error from translation API: ', { e });
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { MiFollowing } from '@/models/_.js';
|
||||
import type { MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
|
@ -38,7 +41,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
sort: { type: 'string', enum: ['+follower', '-follower', '+localFollower', '-localFollower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
|
||||
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
|
||||
hostname: {
|
||||
|
|
@ -59,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private userEntityService: UserEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
|
|
@ -81,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
switch (ps.sort) {
|
||||
case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
|
||||
case '-follower': query.orderBy('user.followersCount', 'ASC'); break;
|
||||
case '+localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'DESC'); break;
|
||||
case '-localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'ASC'); break;
|
||||
case '+createdAt': query.orderBy('user.id', 'DESC'); break;
|
||||
case '-createdAt': query.orderBy('user.id', 'ASC'); break;
|
||||
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
|
||||
|
|
@ -94,9 +100,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
query.limit(ps.limit);
|
||||
query.offset(ps.offset);
|
||||
|
||||
const users = await query.getMany();
|
||||
const allUsers = await query.getMany();
|
||||
|
||||
// This is not ideal, for a couple of reasons:
|
||||
// 1. It may return less than "limit" results.
|
||||
// 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
|
||||
// Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
|
||||
const usersWithRoles = await Promise.all(allUsers.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
|
||||
const users = usersWithRoles
|
||||
.filter(([,p]) => p.canTrend)
|
||||
.map(([u]) => u);
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
|
||||
});
|
||||
}
|
||||
|
||||
private addLocalFollowers(query: SelectQueryBuilder<MiUser>) {
|
||||
query.innerJoin(qb => {
|
||||
return qb
|
||||
.from(MiFollowing, 'f')
|
||||
.addSelect('f."followeeId"')
|
||||
.addSelect('COUNT(*) FILTER (where f."followerHost" IS NULL)', 'localFollowers')
|
||||
.addSelect('COUNT(*) FILTER (where f."followeeHost" IS NOT NULL)', 'remoteFollowers')
|
||||
.groupBy('"followeeId"');
|
||||
}, 'f', 'user.id = f."followeeId"');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
113
packages/backend/test/unit/core/HttpRequestService.ts
Normal file
113
packages/backend/test/unit/core/HttpRequestService.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import type { Mock } from 'jest-mock';
|
||||
import type { PrivateNetwork } from '@/config.js';
|
||||
import type { Socket } from 'net';
|
||||
import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js';
|
||||
import { parsePrivateNetworks } from '@/config.js';
|
||||
|
||||
describe(HttpRequestService, () => {
|
||||
let allowedPrivateNetworks: PrivateNetwork[] | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
allowedPrivateNetworks = parsePrivateNetworks([
|
||||
'10.0.0.1/32',
|
||||
{ network: '127.0.0.1/32', ports: [1] },
|
||||
{ network: '127.0.0.1/32', ports: [3, 4, 5] },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('isPrivateIp', () => {
|
||||
it('should return false when ip public', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false when ip private and port matches', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false when ip private and all ports undefined', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when ip private and no ports specified', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true when ip private and port does not match', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true when ip private and port is null but ports are specified', () => {
|
||||
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSocketConnect', () => {
|
||||
let fakeSocket: Socket;
|
||||
let fakeSocketMutable: {
|
||||
remoteAddress: string | undefined;
|
||||
remotePort: number | undefined;
|
||||
destroy: Mock<(error?: Error) => void>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fakeSocketMutable = {
|
||||
remoteAddress: '74.125.127.100',
|
||||
remotePort: 80,
|
||||
destroy: jest.fn<(error?: Error) => void>(),
|
||||
};
|
||||
fakeSocket = fakeSocketMutable as unknown as Socket;
|
||||
});
|
||||
|
||||
it('should accept when IP is empty', () => {
|
||||
fakeSocketMutable.remoteAddress = undefined;
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept when IP is invalid', () => {
|
||||
fakeSocketMutable.remoteAddress = 'AB939ajd9jdajsdja8jj';
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept when IP is valid', () => {
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept when IP is private and port match', () => {
|
||||
fakeSocketMutable.remoteAddress = '127.0.0.1';
|
||||
fakeSocketMutable.remotePort = 1;
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject when IP is private and port no match', () => {
|
||||
fakeSocketMutable.remoteAddress = '127.0.0.1';
|
||||
fakeSocketMutable.remotePort = 2;
|
||||
|
||||
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
|
||||
|
||||
expect(fakeSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -176,6 +176,7 @@ export const ROLE_POLICIES = [
|
|||
'canImportMuting',
|
||||
'canImportUserLists',
|
||||
'chatAvailability',
|
||||
'canTrend',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.png';
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export class I18n<T extends ILocale> {
|
|||
if (typeof value === 'string') {
|
||||
const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter);
|
||||
|
||||
// TODO add a flag to suppress this warning from uses of <I18n> component
|
||||
if (parameters.length) {
|
||||
console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,10 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
|
|||
return new Promise((done, fail) => {
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: token,
|
||||
}),
|
||||
body: '{}',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then(res => new Promise<Misskey.entities.MeDetailed | { error: Record<string, any> }>((done2, fail2) => {
|
||||
|
|
|
|||
|
|
@ -73,12 +73,12 @@ const ok = async () => {
|
|||
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
|
||||
croppedCanvas?.toBlob(blob => {
|
||||
if (!blob) return;
|
||||
if (!$i) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('name', `cropped_${props.file.name}`);
|
||||
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
|
||||
if (props.file.comment) { formData.append('comment', props.file.comment);}
|
||||
formData.append('i', $i!.token);
|
||||
if (props.uploadFolder) {
|
||||
formData.append('folderId', props.uploadFolder);
|
||||
} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
|
||||
|
|
@ -88,6 +88,9 @@ const ok = async () => {
|
|||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${$i.token}`,
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
|
||||
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
|
||||
<MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="appearNote.user" :link="!mock" :preview="!mock"/>
|
||||
<div :class="[$style.main, { [$style.clickToOpen]: store.s.clickToOpen }]" @click.stop="store.s.clickToOpen ? noteclick(appearNote.id) : undefined">
|
||||
<div :class="[$style.main, { [$style.clickToOpen]: prefer.s.clickToOpen }]" @click.stop="prefer.s.clickToOpen ? noteclick(appearNote.id) : undefined">
|
||||
<MkNoteHeader :note="appearNote" :mini="true" @click.stop/>
|
||||
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
|
||||
<div style="container-type: inline-size;">
|
||||
|
|
@ -171,30 +171,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</article>
|
||||
</div>
|
||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
|
|
@ -230,7 +207,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
|||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
|
@ -259,7 +236,7 @@ import { prefer } from '@/preferences.js';
|
|||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { store } from '@/store';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -279,8 +256,6 @@ const emit = defineEmits<{
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
|
|
@ -334,9 +309,7 @@ const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
|||
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
|
|
@ -361,31 +334,6 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
|||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
|
||||
*/
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) return result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) return replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) return renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let renoting = false;
|
||||
|
||||
const keymap = {
|
||||
|
|
@ -1389,6 +1337,11 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
padding: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted:hover {
|
||||
background: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
|
||||
.reactionOmitted {
|
||||
|
|
|
|||
|
|
@ -230,13 +230,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -262,7 +256,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
|
@ -292,6 +286,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
|
|||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -342,7 +337,6 @@ const isMyRenote = $i && ($i.id === note.value.userId);
|
|||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
|
||||
|
|
@ -360,6 +354,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
|||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
watch(() => props.expandAllCws, (expandAllCws) => {
|
||||
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
|
||||
});
|
||||
|
|
@ -1199,5 +1195,10 @@ function animatedMFM() {
|
|||
padding: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted:hover {
|
||||
background: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="depth < store.s.numberOfReplies">
|
||||
<template v-if="depth < prefer.s.numberOfReplies">
|
||||
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/>
|
||||
</template>
|
||||
<div v-else :class="$style.more">
|
||||
|
|
@ -73,13 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -101,7 +95,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
|
|
@ -111,7 +105,7 @@ import { getNoteMenu } from '@/utility/get-note-menu.js';
|
|||
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
import { store } from '@/store.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -129,7 +123,6 @@ const props = withDefaults(defineProps<{
|
|||
const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
|
||||
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const muted = computed(() => $i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
|
||||
const translation = ref<any>(null);
|
||||
const translating = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
|
|
@ -173,13 +166,15 @@ async function removeReply(id: Misskey.entities.Note['id']) {
|
|||
}
|
||||
}
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: appearNote,
|
||||
isDeletedRef: isDeleted,
|
||||
// only update replies if we are, in fact, showing replies
|
||||
onReplyCallback: props.detail && props.depth < store.s.numberOfReplies ? addReplyTo : undefined,
|
||||
onDeleteCallback: props.detail && props.depth < store.s.numberOfReplies ? props.onDeleteCallback : undefined,
|
||||
onReplyCallback: props.detail && props.depth < prefer.s.numberOfReplies ? addReplyTo : undefined,
|
||||
onDeleteCallback: props.detail && props.depth < prefer.s.numberOfReplies ? props.onDeleteCallback : undefined,
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
|
|
@ -384,7 +379,7 @@ function menu(): void {
|
|||
if (props.detail) {
|
||||
misskeyApi('notes/children', {
|
||||
noteId: props.note.id,
|
||||
limit: store.s.numberOfReplies,
|
||||
limit: prefer.s.numberOfReplies,
|
||||
showQuotes: false,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
|
|
@ -519,5 +514,10 @@ if (props.detail) {
|
|||
border: 1px solid var(--MI_THEME-divider);
|
||||
margin: 8px 8px 0 8px;
|
||||
border-radius: var(--MI-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted:hover {
|
||||
background: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -280,18 +280,16 @@ const fetchMore = async (): Promise<void> => {
|
|||
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
});
|
||||
await reverseConcat(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
});
|
||||
await reverseConcat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" @click="$emit('select', note.user)">
|
||||
<div v-if="!hardMuted" :class="$style.root" @click="$emit('select', note.user)">
|
||||
<div :class="$style.avatar">
|
||||
<MkAvatar :class="$style.icon" :user="note.user" indictor/>
|
||||
</div>
|
||||
|
|
@ -18,11 +18,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkA>
|
||||
</header>
|
||||
<div>
|
||||
<div v-if="isMuted" :class="[$style.text, $style.muted]">({{ i18n.ts.postFiltered }})</div>
|
||||
<div v-if="muted" :class="[$style.text, $style.muted]">
|
||||
<SkMutedNote :muted="muted" :note="note"></SkMutedNote>
|
||||
</div>
|
||||
<Mfm v-else :class="$style.text" :text="getNoteSummary(note)" :isBlock="true" :plain="true" :nowrap="false" :isNote="true" nyaize="respect" :author="note.user"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
|
||||
so MkNote create empty div instead of no elements
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
@ -30,19 +38,19 @@ import * as Misskey from 'misskey-js';
|
|||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
note: Misskey.entities.Note,
|
||||
isMuted: boolean
|
||||
}>(), {
|
||||
isMuted: false,
|
||||
});
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(event: 'select', user: Misskey.entities.UserLite): void
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const { muted, hardMuted } = checkMutes(props.note);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template #default="{ items: notes }">
|
||||
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true">
|
||||
<SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
|
||||
<SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
</MkPagination>
|
||||
|
|
@ -23,17 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { FollowingFeedTab } from '@/utility/following-feed-utils.js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import type { FollowingFeedTab } from '@/types/following-feed.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue';
|
||||
import { $i } from '@/i.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -84,37 +81,6 @@ const latestNotesPagination: Paging<'notes/following'> = {
|
|||
};
|
||||
|
||||
const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
function isSoftMuted(note: Misskey.entities.Note): boolean {
|
||||
return isMuted(note, $i?.mutedWords);
|
||||
}
|
||||
|
||||
function isHardMuted(note: Misskey.entities.Note): boolean {
|
||||
return isMuted(note, $i?.hardMutedWords);
|
||||
}
|
||||
|
||||
// Match the typing used by Misskey
|
||||
type Mutes = (string | string[])[] | null | undefined;
|
||||
|
||||
// Adapted from MkNote.ts
|
||||
function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean {
|
||||
return checkMute(note, mutes)
|
||||
|| checkMute(note.reply, mutes)
|
||||
|| checkMute(note.renote, mutes);
|
||||
}
|
||||
|
||||
// Adapted from check-word-mute.ts
|
||||
function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean {
|
||||
if (!note) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mutes || mutes.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!checkWordMute(note, $i, mutes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
|
|
|||
45
packages/frontend/src/components/SkMutedNote.vue
Normal file
45
packages/frontend/src/components/SkMutedNote.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ mutedWords }}
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
muted: false | 'sensitiveMute' | string[];
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const mutedWords = computed(() => Array.isArray(props.muted)
|
||||
? props.muted.join(', ')
|
||||
: props.muted);
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
|
@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<SkNoteHeader :note="appearNote" :mini="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[{ [$style.clickToOpen]: store.s.clickToOpen }]" @click.stop="store.s.clickToOpen ? noteclick(appearNote.id) : undefined">
|
||||
<div :class="[{ [$style.clickToOpen]: prefer.s.clickToOpen }]" @click.stop="prefer.s.clickToOpen ? noteclick(appearNote.id) : undefined">
|
||||
<div style="container-type: inline-size;">
|
||||
<p v-if="mergedCW != null" :class="$style.cw">
|
||||
<Mfm
|
||||
|
|
@ -172,30 +172,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</article>
|
||||
</div>
|
||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
|
|
@ -230,7 +207,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
|
@ -259,7 +236,7 @@ import { prefer } from '@/preferences.js';
|
|||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { store } from '@/store';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -279,8 +256,6 @@ const emit = defineEmits<{
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
|
|
@ -334,9 +309,7 @@ const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
|||
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
|
|
@ -361,31 +334,6 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
|||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
|
||||
*/
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) return result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) return replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) return renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let renoting = false;
|
||||
|
||||
const keymap = {
|
||||
|
|
@ -1452,6 +1400,11 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
padding: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted:hover {
|
||||
background: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
|
||||
.reactionOmitted {
|
||||
|
|
|
|||
|
|
@ -235,13 +235,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -267,7 +261,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import SkInstanceTicker from '@/components/SkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
|
@ -297,6 +291,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
|
|||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -348,7 +343,6 @@ const isMyRenote = $i && ($i.id === note.value.userId);
|
|||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
|
||||
|
|
@ -366,6 +360,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
|||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
watch(() => props.expandAllCws, (expandAllCws) => {
|
||||
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
|
||||
});
|
||||
|
|
@ -1273,6 +1269,11 @@ onUnmounted(() => {
|
|||
padding: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted:hover {
|
||||
background: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
|
||||
.badgeRoles {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="depth < store.s.numberOfReplies">
|
||||
<template v-if="depth < prefer.s.numberOfReplies">
|
||||
<SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="props.isReply"/>
|
||||
</template>
|
||||
<div v-else :class="$style.more">
|
||||
|
|
@ -81,18 +81,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef, watch } from 'vue';
|
||||
import { computed, inject, ref, shallowRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
|
|
@ -109,7 +103,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { checkMutes } from '@/utility/check-word-mute.js';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
|
|
@ -119,7 +113,7 @@ import { getNoteMenu } from '@/utility/get-note-menu.js';
|
|||
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
import { store } from '@/store.js';
|
||||
import SkMutedNote from '@/components/SkMutedNote.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -143,7 +137,6 @@ const canRenote = computed(() => ['public', 'home'].includes(props.note.visibili
|
|||
const hideLine = computed(() => props.detail);
|
||||
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
|
||||
const translation = ref<any>(null);
|
||||
const translating = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
|
|
@ -187,13 +180,15 @@ async function removeReply(id: Misskey.entities.Note['id']) {
|
|||
}
|
||||
}
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: appearNote,
|
||||
isDeletedRef: isDeleted,
|
||||
// only update replies if we are, in fact, showing replies
|
||||
onReplyCallback: props.detail && props.depth < store.s.numberOfReplies ? addReplyTo : undefined,
|
||||
onDeleteCallback: props.detail && props.depth < store.s.numberOfReplies ? props.onDeleteCallback : undefined,
|
||||
onReplyCallback: props.detail && props.depth < prefer.s.numberOfReplies ? addReplyTo : undefined,
|
||||
onDeleteCallback: props.detail && props.depth < prefer.s.numberOfReplies ? props.onDeleteCallback : undefined,
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
|
|
@ -398,7 +393,7 @@ function menu(): void {
|
|||
if (props.detail) {
|
||||
misskeyApi('notes/children', {
|
||||
noteId: props.note.id,
|
||||
limit: store.s.numberOfReplies,
|
||||
limit: prefer.s.numberOfReplies,
|
||||
showQuotes: false,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
|
|
@ -607,6 +602,11 @@ if (props.detail) {
|
|||
border: 1px solid var(--MI_THEME-divider);
|
||||
margin: 8px 8px 0 8px;
|
||||
border-radius: var(--MI-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted:hover {
|
||||
background: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
|
||||
// avatar container with line
|
||||
|
|
|
|||
57
packages/frontend/src/components/SkPatternTest.vue
Normal file
57
packages/frontend/src/components/SkPatternTest.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.wordMuteTestLabel }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkTextarea v-model="testWords">
|
||||
<template #caption>{{ i18n.ts.wordMuteTestDescription }}</template>
|
||||
</MkTextarea>
|
||||
<div><MkButton :disabled="!testWords" @click="testWordMutes">{{ i18n.ts.wordMuteTestTest }}</MkButton></div>
|
||||
<div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults }}</div>
|
||||
<div v-else-if="testMatches === ''">{{ i18n.ts.wordMuteTestNoMatch }}</div>
|
||||
<div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import { parseMutes } from '@/utility/parse-mutes';
|
||||
import { checkWordMute } from '@/utility/check-word-mute';
|
||||
|
||||
const props = defineProps<{
|
||||
mutedWords?: string | null,
|
||||
}>();
|
||||
|
||||
const testWords = ref<string | null>(null);
|
||||
const testMatches = ref<string | null>(null);
|
||||
|
||||
function testWordMutes() {
|
||||
if (!testWords.value || !props.mutedWords) {
|
||||
testMatches.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mutes = parseMutes(props.mutedWords);
|
||||
const matches = checkWordMute(testWords.value, null, mutes);
|
||||
testMatches.value = matches ? matches.join(', ') : '';
|
||||
} catch {
|
||||
// Error is displayed by above function
|
||||
testMatches.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
|
@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { FollowingFeedModel } from '@/utility/following-feed-utils.js';
|
||||
import type { FollowingFeedModel } from '@/types/following-feed.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { followersTab } from '@/utility/following-feed-utils.js';
|
||||
import { followersTab } from '@/types/following-feed.js';
|
||||
|
||||
const props = defineProps<{
|
||||
model: FollowingFeedModel,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSelect v-model="type" :class="$style.typeSelect">
|
||||
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
|
||||
<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
|
||||
<option value="isFromInstance">{{ i18n.ts._role._condition.isFromInstance }}</option>
|
||||
<option value="fromBubbleInstance">{{ i18n.ts._role._condition.fromBubbleInstance }}</option>
|
||||
<option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option>
|
||||
<option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option>
|
||||
<option value="isBot">{{ i18n.ts._role._condition.isBot }}</option>
|
||||
|
|
@ -21,6 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
|
||||
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
|
||||
<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
|
||||
<option value="localFollowersLessThanOrEq">{{ i18n.ts._role._condition.localFollowersLessThanOrEq }}</option>
|
||||
<option value="localFollowersMoreThanOrEq">{{ i18n.ts._role._condition.localFollowersMoreThanOrEq }}</option>
|
||||
<option value="localFollowingLessThanOrEq">{{ i18n.ts._role._condition.localFollowingLessThanOrEq }}</option>
|
||||
<option value="localFollowingMoreThanOrEq">{{ i18n.ts._role._condition.localFollowingMoreThanOrEq }}</option>
|
||||
<option value="remoteFollowersLessThanOrEq">{{ i18n.ts._role._condition.remoteFollowersLessThanOrEq }}</option>
|
||||
<option value="remoteFollowersMoreThanOrEq">{{ i18n.ts._role._condition.remoteFollowersMoreThanOrEq }}</option>
|
||||
<option value="remoteFollowingLessThanOrEq">{{ i18n.ts._role._condition.remoteFollowingLessThanOrEq }}</option>
|
||||
<option value="remoteFollowingMoreThanOrEq">{{ i18n.ts._role._condition.remoteFollowingMoreThanOrEq }}</option>
|
||||
<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
|
||||
<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
|
||||
<option value="and">{{ i18n.ts._role._condition.and }}</option>
|
||||
|
|
@ -55,12 +65,44 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #suffix>sec</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
|
||||
<MkInput
|
||||
v-else-if="[
|
||||
'followersLessThanOrEq',
|
||||
'followersMoreThanOrEq',
|
||||
'followingLessThanOrEq',
|
||||
'followingMoreThanOrEq',
|
||||
'localFollowersLessThanOrEq',
|
||||
'localFollowersMoreThanOrEq',
|
||||
'localFollowingLessThanOrEq',
|
||||
'localFollowingMoreThanOrEq',
|
||||
'remoteFollowersLessThanOrEq',
|
||||
'remoteFollowersMoreThanOrEq',
|
||||
'remoteFollowingLessThanOrEq',
|
||||
'remoteFollowingMoreThanOrEq',
|
||||
'notesLessThanOrEq',
|
||||
'notesMoreThanOrEq'
|
||||
].includes(type)"
|
||||
v-model="v.value"
|
||||
type="number"
|
||||
>
|
||||
</MkInput>
|
||||
|
||||
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
|
||||
<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkInput v-else-if="type === 'isFromInstance'" v-model="v.host" type="text">
|
||||
<template #label>{{ i18n.ts._role._condition.isFromInstanceHost }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSwitch v-if="type === 'isFromInstance'" v-model="v.subdomains">
|
||||
<template #label>{{ i18n.ts._role._condition.isFromInstanceSubdomains }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<div v-if="['remoteFollowersLessThanOrEq', 'remoteFollowersMoreThanOrEq', 'remoteFollowingLessThanOrEq', 'remoteFollowingMoreThanOrEq'].includes(type)" :class="$style.warningBanner">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
{{ i18n.ts._role.remoteDataWarning }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -73,6 +115,7 @@ import MkButton from '@/components/MkButton.vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { rolesCache } from '@/cache.js';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
|
|
@ -102,6 +145,7 @@ watch(v, () => {
|
|||
const type = computed({
|
||||
get: () => v.value.type,
|
||||
set: (t) => {
|
||||
// TODO there's a bug here: switching types leaves extra properties in the JSON
|
||||
if (t === 'and') v.value.values = [];
|
||||
if (t === 'or') v.value.values = [];
|
||||
if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
|
||||
|
|
@ -112,8 +156,20 @@ const type = computed({
|
|||
if (t === 'followersMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'followingLessThanOrEq') v.value.value = 10;
|
||||
if (t === 'followingMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'localFollowersLessThanOrEq') v.value.value = 10;
|
||||
if (t === 'localFollowersMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'localFollowingLessThanOrEq') v.value.value = 10;
|
||||
if (t === 'localFollowingMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'remoteFollowersLessThanOrEq') v.value.value = 10;
|
||||
if (t === 'remoteFollowersMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'remoteFollowingLessThanOrEq') v.value.value = 10;
|
||||
if (t === 'remoteFollowingMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'notesLessThanOrEq') v.value.value = 10;
|
||||
if (t === 'notesMoreThanOrEq') v.value.value = 10;
|
||||
if (t === 'isFromInstance') {
|
||||
v.value.host = '';
|
||||
v.value.subdomains = true;
|
||||
}
|
||||
v.value.type = t;
|
||||
},
|
||||
});
|
||||
|
|
@ -163,4 +219,14 @@ function removeSelf() {
|
|||
border-color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.warningBanner {
|
||||
color: var(--MI_THEME-warn);
|
||||
width: 100%;
|
||||
padding: 0 6px;
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
|
||||
<FormSuspense :p="init">
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="translationTimeout" type="number" manualSave @update:modelValue="saveTranslationTimeout">
|
||||
<template #label>{{ i18n.ts.translationTimeoutLabel }}</template>
|
||||
<template #caption>{{ i18n.ts.translationTimeoutCaption }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>DeepL Translation</template>
|
||||
|
||||
|
|
@ -69,6 +74,7 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePage } from '@/page.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
const translationTimeout = ref(0);
|
||||
const deeplAuthKey = ref<string | null>('');
|
||||
const deeplIsPro = ref<boolean>(false);
|
||||
const deeplFreeMode = ref<boolean>(false);
|
||||
|
|
@ -78,6 +84,7 @@ const libreTranslateKey = ref<string | null>('');
|
|||
|
||||
async function init() {
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
translationTimeout.value = meta.translationTimeout;
|
||||
deeplAuthKey.value = meta.deeplAuthKey;
|
||||
deeplIsPro.value = meta.deeplIsPro;
|
||||
deeplFreeMode.value = meta.deeplFreeMode;
|
||||
|
|
@ -86,6 +93,13 @@ async function init() {
|
|||
libreTranslateKey.value = meta.libreTranslateKey;
|
||||
}
|
||||
|
||||
async function saveTranslationTimeout() {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
translationTimeout: translationTimeout.value,
|
||||
});
|
||||
await os.promiseDialog(fetchInstance(true));
|
||||
}
|
||||
|
||||
function save_deepl() {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
deeplAuthKey: deeplAuthKey.value,
|
||||
|
|
@ -93,7 +107,7 @@ function save_deepl() {
|
|||
deeplFreeMode: deeplFreeMode.value,
|
||||
deeplFreeInstance: deeplFreeInstance.value,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
os.promiseDialog(fetchInstance(true));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +116,7 @@ function save_libre() {
|
|||
libreTranslateURL: libreTranslateURL.value,
|
||||
libreTranslateKey: libreTranslateKey.value,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
os.promiseDialog(fetchInstance(true));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
|
||||
|
||||
<!-- TODO translate -->
|
||||
<MkFolder v-if="bubbleTimelineEnabled">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ph-drop ph-bold ph-lg"></i></template>
|
||||
<template #label>Bubble timeline</template>
|
||||
<template #label>{{ i18n.ts.bubbleTimeline }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div v-if="!$i.policies.btlAvailable">
|
||||
<i class="ti ti-alert-triangle"></i> {{ i18n.ts.bubbleTimelineMustBeEnabled }}
|
||||
</div>
|
||||
|
||||
<MkTextarea v-model="bubbleTimeline">
|
||||
<template #caption>Choose which instances should be displayed in the bubble.</template>
|
||||
<template #caption>{{ i18n.ts.bubbleTimelineDescription }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkButton primary @click="save_bubbleTimeline">{{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -47,6 +51,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkTextarea v-model="trustedLinkUrlPatterns">
|
||||
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<SkPatternTest :mutedWords="trustedLinkUrlPatterns"></SkPatternTest>
|
||||
|
||||
<MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -71,6 +78,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkTextarea v-model="sensitiveWords">
|
||||
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<SkPatternTest :mutedWords="sensitiveWords"></SkPatternTest>
|
||||
|
||||
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -83,6 +93,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkTextarea v-model="prohibitedWords">
|
||||
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<SkPatternTest :mutedWords="prohibitedWords"></SkPatternTest>
|
||||
|
||||
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -95,6 +108,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkTextarea v-model="prohibitedWordsForNameOfUser">
|
||||
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<SkPatternTest :mutedWords="prohibitedWordsForNameOfUser"></SkPatternTest>
|
||||
|
||||
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -166,11 +182,12 @@ import { definePage } from '@/page.js';
|
|||
import MkButton from '@/components/MkButton.vue';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import SkPatternTest from '@/components/SkPatternTest.vue';
|
||||
import { $i } from '@/i';
|
||||
|
||||
const enableRegistration = ref<boolean>(false);
|
||||
const emailRequiredForSignup = ref<boolean>(false);
|
||||
const approvalRequiredForSignup = ref<boolean>(false);
|
||||
const bubbleTimelineEnabled = ref<boolean>(false);
|
||||
const sensitiveWords = ref<string>('');
|
||||
const prohibitedWords = ref<string>('');
|
||||
const prohibitedWordsForNameOfUser = ref<string>('');
|
||||
|
|
@ -193,7 +210,6 @@ async function init() {
|
|||
hiddenTags.value = meta.hiddenTags.join('\n');
|
||||
preservedUsernames.value = meta.preservedUsernames.join('\n');
|
||||
bubbleTimeline.value = meta.bubbleInstances.join('\n');
|
||||
bubbleTimelineEnabled.value = meta.policies.btlAvailable;
|
||||
trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n');
|
||||
blockedHosts.value = meta.blockedHosts.join('\n');
|
||||
silencedHosts.value = meta.silencedHosts?.join('\n') ?? '';
|
||||
|
|
|
|||
|
|
@ -797,6 +797,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
|
||||
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.canTrend.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.canTrend.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canTrend)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.canTrend.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkSwitch v-model="role.policies.canTrend.value" :disabled="role.policies.canTrend.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
<MkRange v-model="role.policies.canTrend.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSlot>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -300,6 +300,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
|
||||
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
|
||||
<template #suffix>{{ policies.canTrend ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
<MkSwitch v-model="policies.canTrend">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
|
||||
|
|
|
|||
|
|
@ -908,7 +908,6 @@ function getGameImageDriveFile() {
|
|||
formData.append('file', blob);
|
||||
formData.append('name', `bubble-game-${Date.now()}.png`);
|
||||
formData.append('isSensitive', 'false');
|
||||
formData.append('i', $i.token);
|
||||
if (prefer.s.uploadFolder) {
|
||||
formData.append('folderId', prefer.s.uploadFolder);
|
||||
}
|
||||
|
|
@ -916,6 +915,9 @@ function getGameImageDriveFile() {
|
|||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${$i.token}`,
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template v-if="tag == null">
|
||||
<MkFoldableSection class="_margin">
|
||||
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
|
||||
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.tsx.popularUsersLocal({ name: instance.name ?? host }) }}</template>
|
||||
<MkUserList :pagination="popularUsersLocalF"/>
|
||||
</MkFoldableSection>
|
||||
<MkFoldableSection class="_margin">
|
||||
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsersGlobal }}</template>
|
||||
<MkUserList :pagination="popularUsersF"/>
|
||||
</MkFoldableSection>
|
||||
<MkFoldableSection class="_margin">
|
||||
|
|
@ -65,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { watch, ref, useTemplateRef, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config';
|
||||
import MkUserList from '@/components/MkUserList.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
|
|
@ -73,7 +78,7 @@ import { instance } from '@/instance.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string;
|
||||
tag?: string | undefined;
|
||||
}>();
|
||||
|
||||
const origin = ref('local');
|
||||
|
|
@ -86,43 +91,48 @@ watch(() => props.tag, () => {
|
|||
});
|
||||
|
||||
const tagUsers = computed(() => ({
|
||||
endpoint: 'hashtags/users' as const,
|
||||
endpoint: 'hashtags/users',
|
||||
limit: 30,
|
||||
params: {
|
||||
tag: props.tag,
|
||||
origin: 'combined',
|
||||
sort: '+follower',
|
||||
},
|
||||
}));
|
||||
} as const));
|
||||
|
||||
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
|
||||
const pinnedUsers = { endpoint: 'pinned-users', limit: 10, noPaging: true } as const;
|
||||
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'local',
|
||||
sort: '+follower',
|
||||
} };
|
||||
} } as const;
|
||||
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'local',
|
||||
sort: '+updatedAt',
|
||||
} };
|
||||
} } as const;
|
||||
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'local',
|
||||
state: 'alive',
|
||||
sort: '+createdAt',
|
||||
} };
|
||||
} } as const;
|
||||
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'remote',
|
||||
sort: '+follower',
|
||||
} };
|
||||
} } as const;
|
||||
const popularUsersLocalF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'remote',
|
||||
sort: '+localFollower',
|
||||
} } as const;
|
||||
const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'combined',
|
||||
sort: '+updatedAt',
|
||||
} };
|
||||
} } as const;
|
||||
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'combined',
|
||||
sort: '+createdAt',
|
||||
} };
|
||||
} } as const;
|
||||
|
||||
misskeyApi('hashtags/list', {
|
||||
sort: '+attachedLocalUsers',
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ import { i18n } from '@/i18n.js';
|
|||
import MkSwiper from '@/components/MkSwiper.vue';
|
||||
import MkPageHeader from '@/components/global/MkPageHeader.vue';
|
||||
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
|
||||
import { createModel, createHeaderItem, followingFeedTabs, followingTabIcon, followingTabName, followingTab } from '@/utility/following-feed-utils.js';
|
||||
import { createModel, createHeaderItem, followingTabIcon, followingTabName } from '@/utility/following-feed-utils.js';
|
||||
import { followingTab, followingFeedTabs } from '@/types/following-feed.js';
|
||||
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
|
||||
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
|
||||
import { useRouter } from '@/router.js';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
|
||||
<SkPatternTest :mutedWords="mutedWords"></SkPatternTest>
|
||||
|
||||
<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -19,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref, watch } from 'vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { parseMutes } from '@/utility/parse-mutes';
|
||||
import SkPatternTest from '@/components/SkPatternTest.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
muted: (string[] | string)[];
|
||||
|
|
@ -30,7 +34,7 @@ const emit = defineEmits<{
|
|||
(ev: 'save', value: (string[] | string)[]): void;
|
||||
}>();
|
||||
|
||||
const render = (mutedWords) => mutedWords.map(x => {
|
||||
const render = (mutedWords: (string | string[])[]) => mutedWords.map(x => {
|
||||
if (Array.isArray(x)) {
|
||||
return x.join(' ');
|
||||
} else {
|
||||
|
|
@ -46,47 +50,15 @@ watch(mutedWords, () => {
|
|||
});
|
||||
|
||||
async function save() {
|
||||
const parseMutes = (mutes) => {
|
||||
// split into lines, remove empty lines and unnecessary whitespace
|
||||
let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
|
||||
|
||||
// check each line if it is a RegExp or not
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const regexp = line.match(/^\/(.+)\/(.*)$/);
|
||||
if (regexp) {
|
||||
// check that the RegExp is valid
|
||||
try {
|
||||
new RegExp(regexp[1], regexp[2]);
|
||||
// note that regex lines will not be split by spaces!
|
||||
} catch (err: any) {
|
||||
// invalid syntax: do not save, do not reset changed flag
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.regexpError,
|
||||
text: i18n.tsx.regexpErrorDescription({ tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
|
||||
});
|
||||
// re-throw error so these invalid settings are not saved
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
lines[i] = line.split(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMutes(mutedWords.value);
|
||||
} catch (err) {
|
||||
const parsed = parseMutes(mutedWords.value);
|
||||
|
||||
emit('save', parsed);
|
||||
|
||||
changed.value = false;
|
||||
} catch {
|
||||
// already displayed error message in parseMutes
|
||||
return;
|
||||
}
|
||||
|
||||
emit('save', parsed);
|
||||
|
||||
changed.value = false;
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -117,18 +117,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<hr>
|
||||
|
||||
<SearchMarker :keywords="['replies']">
|
||||
<FormSection>
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="defaultWithReplies"><SearchLabel>{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</SearchLabel></MkSwitch>
|
||||
<MkButton danger @click="updateRepliesAll(true)"><i class="ph-chats ph-bold ph-lg"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
|
||||
<MkButton danger @click="updateRepliesAll(false)"><i class="ph-chat ph-bold ph-lg"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
|
||||
</div>
|
||||
</FormSection>
|
||||
</SearchMarker>
|
||||
|
||||
<hr>
|
||||
|
||||
<FormSlot>
|
||||
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
||||
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
|
||||
|
|
|
|||
|
|
@ -814,6 +814,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label><SearchLabel>{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</SearchLabel></template>
|
||||
</MkSwitch>
|
||||
</MkPreferenceContainer>
|
||||
<div class="_buttons">
|
||||
<MkButton danger @click="updateRepliesAll(true)"><i class="ph-chats ph-bold ph-lg"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
|
||||
<MkButton danger @click="updateRepliesAll(false)"><i class="ph-chat ph-bold ph-lg"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
|
||||
</div>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
|
||||
|
|
@ -824,6 +828,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
|
||||
<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
|
||||
<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
|
||||
<option value="disabled">{{ i18n.ts._serverDisconnectedBehavior.disabled }}</option>
|
||||
</MkSelect>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
|
@ -1217,6 +1222,16 @@ async function testNotificationDot() {
|
|||
}
|
||||
}
|
||||
|
||||
async function updateRepliesAll(withReplies: boolean) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
misskeyApi('following/update-all', { withReplies });
|
||||
}
|
||||
|
||||
function save() {
|
||||
misskeyApi('i/update', {
|
||||
defaultCWPriority: defaultCWPriority.value,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import type { Plugin } from '@/plugin.js';
|
|||
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||
import type { DeckProfile } from '@/deck.js';
|
||||
import type { PreferencesDefinition } from './manager.js';
|
||||
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
|
||||
import type { FollowingFeedState } from '@/types/following-feed.js';
|
||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||
import { searchEngineMap } from '@/utility/search-engine-map.js';
|
||||
import { defaultFollowingFeedState } from '@/utility/following-feed-utils.js';
|
||||
import { defaultFollowingFeedState } from '@/types/following-feed.js';
|
||||
|
||||
/** サウンド設定 */
|
||||
export type SoundStore = {
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ export async function signout() {
|
|||
await window.fetch(`${apiUrl}/sw/unregister`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: $i.token,
|
||||
endpoint: push.endpoint,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${$i.token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import darkTheme from '@@/themes/d-green-lime.json5';
|
|||
import { hemisphere } from '@@/js/intl-const.js';
|
||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||
import type { Plugin } from '@/plugin.js';
|
||||
import type { FollowingFeedState } from '@/types/following-feed.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { Pizzax } from '@/lib/pizzax.js';
|
||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||
import { defaultFollowingFeedState } from '@/utility/following-feed-utils.js';
|
||||
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
|
||||
import { defaultFollowingFeedState } from '@/types/following-feed.js';
|
||||
import { searchEngineMap } from '@/utility/search-engine-map.js';
|
||||
|
||||
/**
|
||||
|
|
@ -457,7 +457,7 @@ export const store = markRaw(new Pizzax('base', {
|
|||
},
|
||||
sound_note: {
|
||||
where: 'device',
|
||||
default: { type: 'syuilo/n-aec', volume: 1 },
|
||||
default: { type: 'syuilo/n-aec', volume: 0 },
|
||||
},
|
||||
sound_noteMy: {
|
||||
where: 'device',
|
||||
|
|
|
|||
36
packages/frontend/src/types/following-feed.ts
Normal file
36
packages/frontend/src/types/following-feed.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { WritableComputedRef } from 'vue';
|
||||
|
||||
export const followingTab = 'following' as const;
|
||||
export const mutualsTab = 'mutuals' as const;
|
||||
export const followersTab = 'followers' as const;
|
||||
export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const;
|
||||
export type FollowingFeedTab = typeof followingFeedTabs[number];
|
||||
|
||||
export type FollowingFeedState = {
|
||||
withNonPublic: boolean,
|
||||
withQuotes: boolean,
|
||||
withBots: boolean,
|
||||
withReplies: boolean,
|
||||
onlyFiles: boolean,
|
||||
userList: FollowingFeedTab,
|
||||
remoteWarningDismissed: boolean,
|
||||
};
|
||||
|
||||
export type FollowingFeedModel = {
|
||||
[Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>;
|
||||
};
|
||||
|
||||
export const defaultFollowingFeedState: FollowingFeedState = {
|
||||
withNonPublic: false,
|
||||
withQuotes: false,
|
||||
withBots: true,
|
||||
withReplies: false,
|
||||
onlyFiles: false,
|
||||
userList: followingTab,
|
||||
remoteWarningDismissed: false,
|
||||
};
|
||||
|
|
@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div>
|
||||
|
||||
<SkOneko v-if="store.r.oneko.value"/>
|
||||
<SkOneko v-if="prefer.r.oneko.value"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
@ -115,7 +115,6 @@ import { i18n } from '@/i18n.js';
|
|||
import { prefer } from '@/preferences.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
|
||||
import { store } from '@/store.js';
|
||||
|
||||
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
|
||||
const XUpload = defineAsyncComponent(() => import('./upload.vue'));
|
||||
|
|
|
|||
|
|
@ -19,18 +19,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts">
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { Column } from '@/deck.js';
|
||||
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
|
||||
import type { FollowingFeedState } from '@/types/following-feed.js';
|
||||
export type FollowingColumn = Column & Partial<FollowingFeedState>;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FollowingFeedTab } from '@/utility/following-feed-utils.js';
|
||||
import type { FollowingFeedTab } from '@/types/following-feed.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { getColumn, updateColumn } from '@/deck.js';
|
||||
import XColumn from '@/ui/deck/column.vue';
|
||||
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
|
||||
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
|
||||
import { createModel, createOptionsMenu, followingTab, followingTabName, followingTabIcon, followingFeedTabs } from '@/utility/following-feed-utils.js';
|
||||
import { followingTab, followingFeedTabs } from '@/types/following-feed.js';
|
||||
import { createModel, createOptionsMenu, followingTabName, followingTabIcon } from '@/utility/following-feed-utils.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
|
|
|
|||
|
|
@ -3,40 +3,88 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { inject, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { $i } from '@/i';
|
||||
|
||||
export function checkMutes(noteToCheck: Misskey.entities.Note, withHardMute = false) {
|
||||
const muted = ref(checkMute(noteToCheck, $i?.mutedWords));
|
||||
const hardMuted = ref(withHardMute && checkMute(noteToCheck, $i?.hardMutedWords, true));
|
||||
return { muted, hardMuted };
|
||||
}
|
||||
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null): false;
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null, checkOnly: false): false;
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null, checkOnly?: boolean): false | 'sensitiveMute';
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null): string[] | false;
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null, checkOnly: false): string[] | false;
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null, checkOnly?: boolean): string[] | false | 'sensitiveMute';
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null, checkOnly = false): string[] | false | 'sensitiveMute' {
|
||||
if (mutes != null) {
|
||||
const result =
|
||||
checkWordMute(note, $i, mutes)
|
||||
|| checkWordMute(note.reply, $i, mutes)
|
||||
|| checkWordMute(note.renote, $i, mutes);
|
||||
|
||||
// Only continue to sensitiveMute if we don't match any *actual* mutes
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean> | null>('tl_withSensitive', null);
|
||||
if (inTimeline && tl_withSensitive?.value === false && note.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkWordMute(note: string | Misskey.entities.Note | undefined | null, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): string[] | false {
|
||||
if (note == null) return false;
|
||||
|
||||
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
|
||||
// 自分自身
|
||||
if (me && (note.userId === me.id)) return false;
|
||||
if (me && typeof(note) === 'object' && (note.userId === me.id)) return false;
|
||||
|
||||
if (mutedWords.length > 0) {
|
||||
const text = getNoteText(note);
|
||||
const text = typeof(note) === 'object' ? getNoteText(note) : note;
|
||||
|
||||
if (text === '') return false;
|
||||
|
||||
const matched = mutedWords.filter(filter => {
|
||||
const matched = mutedWords.reduce((matchedWords, filter) => {
|
||||
if (Array.isArray(filter)) {
|
||||
// Clean up
|
||||
const filteredFilter = filter.filter(keyword => keyword !== '');
|
||||
if (filteredFilter.length === 0) return false;
|
||||
|
||||
return filteredFilter.every(keyword => text.includes(keyword));
|
||||
if (filteredFilter.length > 0 && filteredFilter.every(keyword => text.includes(keyword))) {
|
||||
const fullFilter = filteredFilter.join(' ');
|
||||
matchedWords.add(fullFilter);
|
||||
}
|
||||
} else {
|
||||
// represents RegExp
|
||||
const regexp = filter.match(/^\/(.+)\/(.*)$/);
|
||||
|
||||
// This should never happen due to input sanitisation.
|
||||
if (!regexp) return false;
|
||||
|
||||
try {
|
||||
return new RegExp(regexp[1], regexp[2]).test(text);
|
||||
} catch (err) {
|
||||
// This should never happen due to input sanitisation.
|
||||
return false;
|
||||
if (regexp) {
|
||||
try {
|
||||
const flags = regexp[2].includes('g') ? regexp[2] : (regexp[2] + 'g');
|
||||
const matches = text.matchAll(new RegExp(regexp[1], flags));
|
||||
for (const match of matches) {
|
||||
matchedWords.add(match[0]);
|
||||
}
|
||||
} catch {
|
||||
// This should never happen due to input sanitisation.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (matched.length > 0) return matched;
|
||||
return matchedWords;
|
||||
}, new Set<string>());
|
||||
|
||||
// Nested arrays are intentional, otherwise the note components will join with space (" ") and it's confusing.
|
||||
if (matched.size > 0) return Array.from(matched);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@ import { computed } from 'vue';
|
|||
import type { Ref, WritableComputedRef } from 'vue';
|
||||
import type { PageHeaderItem } from '@/types/page-header.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { FollowingFeedTab, FollowingFeedState, FollowingFeedModel } from '@/types/following-feed.js';
|
||||
import { deepMerge } from '@/utility/merge.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { popupMenu } from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
export const followingTab = 'following' as const;
|
||||
export const mutualsTab = 'mutuals' as const;
|
||||
export const followersTab = 'followers' as const;
|
||||
export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const;
|
||||
export type FollowingFeedTab = typeof followingFeedTabs[number];
|
||||
import { followingTab, followersTab, mutualsTab, defaultFollowingFeedState } from '@/types/following-feed.js';
|
||||
|
||||
export function followingTabName(tab: FollowingFeedTab): string;
|
||||
export function followingTabName(tab: FollowingFeedTab | null | undefined): null;
|
||||
|
|
@ -33,30 +29,6 @@ export function followingTabIcon(tab: FollowingFeedTab | null | undefined): stri
|
|||
return 'ph-user-check ph-bold ph-lg';
|
||||
}
|
||||
|
||||
export type FollowingFeedModel = {
|
||||
[Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>;
|
||||
};
|
||||
|
||||
export type FollowingFeedState = {
|
||||
withNonPublic: boolean,
|
||||
withQuotes: boolean,
|
||||
withBots: boolean,
|
||||
withReplies: boolean,
|
||||
onlyFiles: boolean,
|
||||
userList: FollowingFeedTab,
|
||||
remoteWarningDismissed: boolean,
|
||||
};
|
||||
|
||||
export const defaultFollowingFeedState: FollowingFeedState = {
|
||||
withNonPublic: false,
|
||||
withQuotes: false,
|
||||
withBots: true,
|
||||
withReplies: false,
|
||||
onlyFiles: false,
|
||||
userList: followingTab,
|
||||
remoteWarningDismissed: false,
|
||||
};
|
||||
|
||||
interface StorageInterface {
|
||||
readonly state: Ref<Partial<FollowingFeedState>>;
|
||||
save(updated: Partial<FollowingFeedState>): void;
|
||||
|
|
@ -177,3 +149,4 @@ function createDefaultStorage(): Ref<StorageInterface> {
|
|||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -293,12 +293,12 @@ export function getNoteMenu(props: {
|
|||
async function translate(): Promise<void> {
|
||||
if (props.translation.value != null) return;
|
||||
props.translating.value = true;
|
||||
const res = await misskeyApi('notes/translate', {
|
||||
props.translation.value = await misskeyApi('notes/translate', {
|
||||
noteId: appearNote.id,
|
||||
targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
|
||||
}).finally(() => {
|
||||
props.translating.value = false;
|
||||
});
|
||||
props.translating.value = false;
|
||||
props.translation.value = res;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function misskeyApi<
|
|||
_ResT = ResT extends void ? Response<E, P> : ResT,
|
||||
>(
|
||||
endpoint: E,
|
||||
data: P & { i?: string | null; } = {} as any,
|
||||
data: P & { i?: string | null; } = {} as P & {},
|
||||
token?: string | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<_ResT> {
|
||||
|
|
@ -41,9 +41,23 @@ export function misskeyApi<
|
|||
};
|
||||
|
||||
const promise = new Promise<_ResT>((resolve, reject) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Append a credential
|
||||
if ($i) data.i = $i.token;
|
||||
if (token !== undefined) data.i = token;
|
||||
const auth = token !== undefined
|
||||
? token
|
||||
: data.i !== undefined
|
||||
? data.i
|
||||
: $i?.token;
|
||||
|
||||
if (auth) {
|
||||
headers['Authorization'] = `Bearer ${auth}`;
|
||||
}
|
||||
|
||||
// Don't let the body value leak through
|
||||
delete data.i;
|
||||
|
||||
// Send request
|
||||
window.fetch(`${apiUrl}/${endpoint}`, {
|
||||
|
|
@ -51,9 +65,7 @@ export function misskeyApi<
|
|||
body: JSON.stringify(data),
|
||||
credentials: 'omit',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
const body = res.status === 204 ? null : await res.json();
|
||||
|
|
@ -81,7 +93,9 @@ export function misskeyApiGet<
|
|||
_ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
|
||||
>(
|
||||
endpoint: E,
|
||||
data: P = {} as any,
|
||||
data: P & { i?: string | null; } = {} as P & {},
|
||||
token?: string | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<_ResT> {
|
||||
pendingApiRequestsCount.value++;
|
||||
|
||||
|
|
@ -92,11 +106,27 @@ export function misskeyApiGet<
|
|||
const query = new URLSearchParams(data as any);
|
||||
|
||||
const promise = new Promise<_ResT>((resolve, reject) => {
|
||||
// Append a credential
|
||||
const auth = token !== undefined
|
||||
? token
|
||||
: data.i !== undefined
|
||||
? data.i
|
||||
: $i?.token;
|
||||
|
||||
const headers = auth
|
||||
? { 'Authorization': `Bearer ${auth}` }
|
||||
: undefined;
|
||||
|
||||
// Don't let the body value leak through
|
||||
query.delete('i');
|
||||
|
||||
// Send request
|
||||
window.fetch(`${apiUrl}/${endpoint}?${query}`, {
|
||||
method: 'GET',
|
||||
credentials: 'omit',
|
||||
cache: 'default',
|
||||
headers,
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
const body = res.status === 204 ? null : await res.json();
|
||||
|
||||
|
|
|
|||
41
packages/frontend/src/utility/parse-mutes.ts
Normal file
41
packages/frontend/src/utility/parse-mutes.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export type Mutes = (string | string[])[];
|
||||
|
||||
export function parseMutes(mutes: string): Mutes {
|
||||
// split into lines, remove empty lines and unnecessary whitespace
|
||||
const lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
|
||||
const outLines: Mutes = Array.from(lines);
|
||||
|
||||
// check each line if it is a RegExp or not
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const regexp = line.match(/^\/(.+)\/(.*)$/);
|
||||
if (regexp) {
|
||||
// check that the RegExp is valid
|
||||
try {
|
||||
new RegExp(regexp[1], regexp[2]);
|
||||
// note that regex lines will not be split by spaces!
|
||||
} catch (err: any) {
|
||||
// invalid syntax: do not save, do not reset changed flag
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.regexpError,
|
||||
text: i18n.tsx.regexpErrorDescription({ tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
|
||||
});
|
||||
// re-throw error so these invalid settings are not saved
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
outLines[i] = line.split(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return outLines;
|
||||
}
|
||||
|
|
@ -5520,6 +5520,7 @@ export type components = {
|
|||
scheduleNoteMax: number;
|
||||
/** @enum {string} */
|
||||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
canTrend: boolean;
|
||||
};
|
||||
ReversiGameLite: {
|
||||
/** Format: id */
|
||||
|
|
@ -9249,6 +9250,7 @@ export type operations = {
|
|||
enableReactionsBuffering: boolean;
|
||||
notesPerOneAd: number;
|
||||
backgroundImageUrl: string | null;
|
||||
translationTimeout: number;
|
||||
deeplAuthKey: string | null;
|
||||
deeplIsPro: boolean;
|
||||
deeplFreeMode: boolean;
|
||||
|
|
@ -12159,6 +12161,7 @@ export type operations = {
|
|||
maintainerName?: string | null;
|
||||
maintainerEmail?: string | null;
|
||||
langs?: string[];
|
||||
translationTimeout?: number;
|
||||
deeplAuthKey?: string | null;
|
||||
deeplIsPro?: boolean;
|
||||
deeplFreeMode?: boolean;
|
||||
|
|
@ -21747,6 +21750,8 @@ export type operations = {
|
|||
* @enum {string}
|
||||
*/
|
||||
origin?: 'combined' | 'local' | 'remote';
|
||||
/** @default false */
|
||||
trending?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -28709,15 +28714,11 @@ export type operations = {
|
|||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
sourceLang: string;
|
||||
text: string;
|
||||
sourceLang?: string;
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description OK (without any results) */
|
||||
204: {
|
||||
content: never;
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
content: {
|
||||
|
|
@ -31524,7 +31525,7 @@ export type operations = {
|
|||
/** @default 0 */
|
||||
offset?: number;
|
||||
/** @enum {string} */
|
||||
sort?: '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt';
|
||||
sort?: '+follower' | '-follower' | '+localFollower' | '-localFollower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt';
|
||||
/**
|
||||
* @default all
|
||||
* @enum {string}
|
||||
|
|
|
|||
|
|
@ -239,9 +239,23 @@ _role:
|
|||
canImportNotes: "Can import notes"
|
||||
canUpdateBioMedia: "Allow users to edit their avatar or banner"
|
||||
scheduleNoteMax: "Maximum number of scheduled notes"
|
||||
canTrend: "Can appear in trending notes / users"
|
||||
_condition:
|
||||
isLocked: "Private account"
|
||||
isExplorable: "Account is discoverable"
|
||||
isFromInstance: "Is from a specific instance"
|
||||
isFromInstanceHost: "Hostname (case-insensitive)"
|
||||
isFromInstanceSubdomains: "Match subdomains"
|
||||
fromBubbleInstance: "User is from a bubble instance"
|
||||
localFollowersLessThanOrEq: "Has X or fewer local followers"
|
||||
localFollowersMoreThanOrEq: "Has X or more local followers"
|
||||
localFollowingLessThanOrEq: "Follows X or fewer local accounts"
|
||||
localFollowingMoreThanOrEq: "Follows X or more local accounts"
|
||||
remoteFollowersLessThanOrEq: "Has X or fewer remote followers"
|
||||
remoteFollowersMoreThanOrEq: "Has X or more remote followers"
|
||||
remoteFollowingLessThanOrEq: "Follows X or fewer remote accounts"
|
||||
remoteFollowingMoreThanOrEq: "Follows X or more remote accounts"
|
||||
remoteDataWarning: "This condition may be incorrect for remote users."
|
||||
_emailUnavailable:
|
||||
banned: "This email address is banned"
|
||||
_signup:
|
||||
|
|
@ -540,3 +554,20 @@ enableProxyAccountDescription: "If disabled, then the proxy account will not be
|
|||
_confirmPollEdit:
|
||||
title: Are you sure you want to edit this poll
|
||||
text: Editing this poll will cause it to lose all previous votes
|
||||
|
||||
wordMuteTestLabel: "Test patterns"
|
||||
wordMuteTestDescription: "Enter some text here to test your word patterns. The matched words, if any, will be displayed below."
|
||||
wordMuteTestTest: "Test"
|
||||
wordMuteTestMatch: "Matched words: {words}"
|
||||
wordMuteTestNoResults: "No results yet, enter some text and click \"Test\" to check it."
|
||||
wordMuteTestNoMatch: "Text does not match any patterns."
|
||||
|
||||
bubbleTimeline: "Bubble timeline"
|
||||
bubbleTimelineDescription: "Choose which instances should be displayed in the bubble."
|
||||
bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, and must be enabled via roles."
|
||||
|
||||
popularUsersGlobal: "Users popular on the global network"
|
||||
popularUsersLocal: "Users popular on {name}"
|
||||
|
||||
translationTimeoutLabel: "Translation timeout"
|
||||
translationTimeoutCaption: "Timeout in milliseconds for translation API requests."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue