Merge branch Sharkey:develop into trackeropt

This commit is contained in:
Vavency 2025-05-15 18:07:46 +00:00
commit 249fe253a0
76 changed files with 1789 additions and 577 deletions

View file

@ -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']

View file

@ -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.

View file

@ -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']

View file

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

@ -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;

View file

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

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddMetaTranslationTimeout1747023091463 {
name = 'AddMetaTranslationTimeout1747023091463'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "translationTimeout" integer NOT NULL DEFAULT '5000'`);
await queryRunner.query(`COMMENT ON COLUMN "meta"."translationTimeout" IS 'Timeout in milliseconds for translation API requests'`);
}
async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "meta"."translationTimeout" IS 'Timeout in milliseconds for translation API requests'`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "translationTimeout"`);
}
}

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

@ -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)) {

View file

@ -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,

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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';

View file

@ -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 {

View file

@ -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,

View file

@ -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;

View file

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
await this.featuredService.updateGalleryPostsRanking(post.id, 1);
await this.featuredService.updateGalleryPostsRanking(post, 1);
}
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);

View file

@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
await this.featuredService.updateGalleryPostsRanking(post.id, -1);
await this.featuredService.updateGalleryPostsRanking(post, -1);
}
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);

View file

@ -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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { jest } from '@jest/globals';
import type { Mock } from 'jest-mock';
import type { PrivateNetwork } from '@/config.js';
import type { Socket } from 'net';
import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js';
import { parsePrivateNetworks } from '@/config.js';
describe(HttpRequestService, () => {
let allowedPrivateNetworks: PrivateNetwork[] | undefined;
beforeEach(() => {
allowedPrivateNetworks = parsePrivateNetworks([
'10.0.0.1/32',
{ network: '127.0.0.1/32', ports: [1] },
{ network: '127.0.0.1/32', ports: [3, 4, 5] },
]);
});
describe('isPrivateIp', () => {
it('should return false when ip public', () => {
const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
expect(result).toBeFalsy();
});
it('should return false when ip private and port matches', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
expect(result).toBeFalsy();
});
it('should return false when ip private and all ports undefined', () => {
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined);
expect(result).toBeFalsy();
});
it('should return true when ip private and no ports specified', () => {
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
expect(result).toBeTruthy();
});
it('should return true when ip private and port does not match', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
expect(result).toBeTruthy();
});
it('should return true when ip private and port is null but ports are specified', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined);
expect(result).toBeTruthy();
});
});
describe('validateSocketConnect', () => {
let fakeSocket: Socket;
let fakeSocketMutable: {
remoteAddress: string | undefined;
remotePort: number | undefined;
destroy: Mock<(error?: Error) => void>;
};
beforeEach(() => {
fakeSocketMutable = {
remoteAddress: '74.125.127.100',
remotePort: 80,
destroy: jest.fn<(error?: Error) => void>(),
};
fakeSocket = fakeSocketMutable as unknown as Socket;
});
it('should accept when IP is empty', () => {
fakeSocketMutable.remoteAddress = undefined;
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should accept when IP is invalid', () => {
fakeSocketMutable.remoteAddress = 'AB939ajd9jdajsdja8jj';
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should accept when IP is valid', () => {
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should accept when IP is private and port match', () => {
fakeSocketMutable.remoteAddress = '127.0.0.1';
fakeSocketMutable.remotePort = 1;
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should reject when IP is private and port no match', () => {
fakeSocketMutable.remoteAddress = '127.0.0.1';
fakeSocketMutable.remotePort = 2;
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).toHaveBeenCalled();
});
});
});

View file

@ -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';

View file

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

View file

@ -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) => {

View file

@ -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 => {

View file

@ -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 FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, $i, mutedWords);
if (Array.isArray(result)) return result;
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
if (Array.isArray(replyResult)) return replyResult;
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
if (Array.isArray(renoteResult)) return renoteResult;
}
if (checkOnly) return false;
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute';
}
return false;
}
let renoting = false;
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 {

View file

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

View file

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

View file

@ -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;

View file

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

View file

@ -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">

View file

@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
<template #word>
{{ mutedWords }}
</template>
</I18n>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed } from 'vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
muted: false | 'sensitiveMute' | string[];
note: Misskey.entities.Note;
}>();
const mutedWords = computed(() => Array.isArray(props.muted)
? props.muted.join(', ')
: props.muted);
</script>
<style module lang="scss">
</style>

View file

@ -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 FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, $i, mutedWords);
if (Array.isArray(result)) return result;
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
if (Array.isArray(replyResult)) return replyResult;
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
if (Array.isArray(renoteResult)) return renoteResult;
}
if (checkOnly) return false;
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute';
}
return false;
}
let renoting = false;
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 {

View file

@ -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 {

View file

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

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

View file

@ -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,

View file

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

View file

@ -8,6 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<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));
});
}

View file

@ -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') ?? '';

View file

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

View file

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

View file

@ -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 => {

View file

@ -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',

View file

@ -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';

View file

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

View file

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

View file

@ -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,

View file

@ -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 = {

View file

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

View file

@ -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',

View 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,
};

View file

@ -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'));

View file

@ -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';

View file

@ -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;

View file

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

View file

@ -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[] = [];

View file

@ -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();

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

View file

@ -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}

View file

@ -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."