Merge Sharkey Develop

This commit is contained in:
Ozzy 2025-06-04 22:56:43 +09:30
parent 09f51889ff
commit 6e64e3d38f
Signed by: Ozzy
GPG key ID: A48065BC872B6549
55 changed files with 1091 additions and 572 deletions

12
locales/index.d.ts vendored
View file

@ -13157,6 +13157,18 @@ export interface Locale extends ILocale {
* Timeout in milliseconds for translation API requests. * Timeout in milliseconds for translation API requests.
*/ */
"translationTimeoutCaption": string; "translationTimeoutCaption": string;
/**
* Attribution Domains
*/
"attributionDomains": string;
/**
* A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:
*/
"attributionDomainsDescription": string;
/**
* Written by {user}
*/
"writtenBy": ParameterizedString<"user">;
/** /**
* Following (Pub) * Following (Pub)
*/ */

View file

@ -1,6 +1,6 @@
{ {
"name": "quollkey", "name": "quollkey",
"version": "2025.6.0-Q", "version": "2025.6.1-dev",
"codename": "quoll", "codename": "quoll",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: piuvas and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddAttributionDomains1748096357260 {
name = 'AddAttributionDomains1748096357260'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "attributionDomains" text array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "attributionDomains"`);
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ReplaceNoteUserHostIndex1748990452958 {
name = 'ReplaceNoteUserHostIndex1748990452958'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_7125a826ab192eb27e11d358a5"`);
await queryRunner.query(`
create index "IDX_note_userHost_id"
on "note" ("userHost", "id" desc)
nulls not distinct`);
await queryRunner.query(`comment on index "IDX_note_userHost_id" is 'User host with ID included'`);
}
async down(queryRunner) {
await queryRunner.query(`drop index if exists "IDX_note_userHost_id"`);
await queryRunner.query(`CREATE INDEX "IDX_7125a826ab192eb27e11d358a5" ON "note" ("userHost") `);
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FixIDXInstanceHostKey1748990662839 {
async up(queryRunner) {
// must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
await queryRunner.query(`
create index "IDX_instance_host_key"
on "instance" ((lower(reverse("host"::text)) || '.'::text) text_pattern_ops)
include ("host")
`);
await queryRunner.query(`comment on index "IDX_instance_host_key" is 'Expression index for finding instances by base domain'`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`);
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CreateIDXNoteForTimelines1748991828473 {
async up(queryRunner) {
await queryRunner.query(`
create index "IDX_note_for_timelines"
on "note" ("id" desc, "channelId", "visibility", "userHost")
include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost")
NULLS NOT DISTINCT`);
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_note_for_timelines"`);
}
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CreateIDXInstanceHostFilters1748992017688 {
async up(queryRunner) {
await queryRunner.query(`
create index "IDX_instance_host_filters"
on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`);
await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_instance_host_filters"`);
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CreateStatistics1748992128683 {
async up(queryRunner) {
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`);
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);
await queryRunner.query(`CREATE STATISTICS "STTS_note_replyId_replyUserId_replyUserHost" (dependencies) ON "replyId", "replyUserId", "replyUserHost" FROM "note"`)
await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost" (dependencies) ON "renoteId", "renoteUserId", "renoteUserHost" FROM "note"`);
await queryRunner.query(`CREATE STATISTICS "STTS_note_userId_userHost" (mcv) ON "userId", "userHost" FROM "note"`);
await queryRunner.query(`CREATE STATISTICS "STTS_note_replyUserId_replyUserHost" (mcv) ON "replyUserId", "replyUserHost" FROM "note"`);
await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteUserId_renoteUserHost" (mcv) ON "renoteUserId", "renoteUserHost" FROM "note"`);
await queryRunner.query(`ANALYZE "note", "instance"`);
}
async down(queryRunner) {
await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isBubbled"`);
await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isSilenced"`);
await queryRunner.query(`DROP STATISTICS "STTS_note_replyId_replyUserId_replyUserHost"`);
await queryRunner.query(`DROP STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost"`);
await queryRunner.query(`DROP STATISTICS "STTS_note_userId_userHost"`);
await queryRunner.query(`DROP STATISTICS "STTS_note_replyUserId_replyUserHost"`);
await queryRunner.query(`DROP STATISTICS "STTS_note_renoteUserId_renoteUserHost"`);
await queryRunner.query(`ANALYZE "note", "instance"`);
}
}

View file

@ -4,13 +4,14 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm'; import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js'; import { MiInstance } from '@/models/Instance.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
@Injectable() @Injectable()
export class QueryService { export class QueryService {
@ -36,6 +37,9 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository) @Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
@Inject(DI.instancesRepository)
private readonly instancesRepository: InstancesRepository,
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@ -72,215 +76,349 @@ export class QueryService {
// ここでいうBlockedは被Blockedの意 // ここでいうBlockedは被Blockedの意
@bindThis @bindThis
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
// 投稿の作者にブロックされていない かつ // 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない // 投稿の引用元の作者にブロックされていない
q return this
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) .andNotBlockingUser(q, 'note.userId', ':meId')
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
qb .orNotBlockingUser(qb, 'note.replyUserId', ':meId')
.where('note.replyUserId IS NULL') .orWhere('note.replyUserId IS NULL')))
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); .andWhere(new Brackets(qb => this
})) .orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
.andWhere(new Brackets(qb => { .orWhere('note.renoteUserId IS NULL')))
qb .setParameters({ meId: me.id });
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
}));
q.setParameters(blockingQuery.getParameters());
} }
@bindThis @bindThis
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') this.andNotBlockingUser(q, ':meId', 'user.id');
.select('blocking.blockeeId') this.andNotBlockingUser(q, 'user.id', ':me.id');
.where('blocking.blockerId = :blockerId', { blockerId: me.id }); return q.setParameters({ meId: me.id });
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
q.setParameters(blockingQuery.getParameters());
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
q.setParameters(blockedQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') return this
.select('threadMuted.threadId') .andNotMutingThread(q, ':meId', 'note.id')
.where('threadMuted.userId = :userId', { userId: me.id }); .andWhere(new Brackets(qb => this
.orNotMutingThread(qb, ':meId', 'note.threadId')
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); .orWhere('note.threadId IS NULL')))
q.andWhere(new Brackets(qb => { .setParameters({ meId: me.id });
qb
.where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
}));
q.setParameters(mutedQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
if (exclude) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
}
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
// 投稿の作者をミュートしていない かつ // 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
q return this
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andNotMutingUser(q, ':meId', 'note.userId', exclude)
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
qb .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
.where('note.replyUserId IS NULL') .orWhere('note.replyUserId IS NULL')))
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); .andWhere(new Brackets(qb => this
})) .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
.andWhere(new Brackets(qb => { .orWhere('note.renoteUserId IS NULL')))
qb // TODO exclude should also pass a host to skip these instances
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances // mute instances
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
qb .andNotMutingInstance(qb, ':meId', 'note.userHost')
.andWhere('note.userHost IS NULL') .orWhere('note.userHost IS NULL')))
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); .andWhere(new Brackets(qb => this
})) .orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
.andWhere(new Brackets(qb => { .orWhere('note.replyUserHost IS NULL')))
qb .andWhere(new Brackets(qb => this
.where('note.replyUserHost IS NULL') .orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); .orWhere('note.renoteUserHost IS NULL')))
})) .setParameters({ meId: me.id });
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') return this
.select('muting.muteeId') .andNotMutingUser(q, ':meId', 'user.id')
.where('muting.muterId = :muterId', { muterId: me.id }); .setParameters({ meId: me.id });
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
q.setParameters(mutingQuery.getParameters());
} }
// This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents.
// NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads.
// For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user.
@bindThis @bindThis
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) { return q.andWhere(new Brackets(qb => {
q.andWhere(new Brackets(qb => { // Public post
qb qb.orWhere('note.visibility = \'public\'')
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\''); .orWhere('note.visibility = \'home\'');
}));
} else {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { if (me != null) {
qb qb
// 公開投稿である // My post
.where(new Brackets(qb => { .orWhere(':meId = note.userId')
qb // Reply to me
.where('note.visibility = \'public\'') .orWhere(':meId = note.replyUserId')
.orWhere('note.visibility = \'home\''); // DM to me
})) .orWhere(':meId = ANY (note.visibleUserIds)')
// または 自分自身 // Mentions me
.orWhere('note.userId = :meId') .orWhere(':meId = ANY (note.mentions)')
// または 自分宛て // Followers-only post
.orWhere(':meIdAsList <@ note.visibleUserIds') .orWhere(new Brackets(qb => this
.orWhere(new Brackets(qb => { .andFollowingUser(qb, ':meId', 'note.userId')
qb .andWhere('note.visibility = \'followers\'')));
// または フォロワー宛ての投稿であり、
.where('note.visibility = \'followers\'')
.andWhere(new Brackets(qb => {
qb
// 自分がフォロワーである
.where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ
.orWhere('note.replyUserId = :meId')
.orWhere(':meIdAsList <@ note.mentions');
}));
}));
}));
q.setParameters({ meId: me.id, meIdAsList: [me.id] }); q.setParameters({ meId: me.id });
} }
}));
} }
@bindThis @bindThis
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') return q
.select('renote_muting.muteeId') .andWhere(new Brackets(qb => this
.where('renote_muting.muterId = :muterId', { muterId: me.id }); .orNotMutingRenote(qb, ':meId', 'note.userId')
q.andWhere(new Brackets(qb => {
qb
.orWhere('note.renoteId IS NULL') .orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL') .orWhere('note.text IS NOT NULL')
.orWhere('note.cw IS NOT NULL') .orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL') .orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = false') .orWhere('note.hasPoll = true')
.orWhere('note.fileIds != \'{}\'') .orWhere('note.fileIds != \'{}\'')))
.orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); .setParameters({ meId: me.id });
}));
q.setParameters(mutingQuery.getParameters());
} }
@bindThis @bindThis
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean, allowSilenced = true): void { public generateExcludedRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>): SelectQueryBuilder<E> {
function checkFor(key: 'user' | 'replyUser' | 'renoteUser') { return q
q.leftJoin(`note.${key}Instance`, `${key}Instance`); .andWhere(new Brackets(qb => qb
q.andWhere(new Brackets(qb => { .orWhere('note.renoteId IS NULL')
qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user .orWhere('note.text IS NOT NULL')
.orWhere(`note.${key}Host IS NULL`); // local .orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL')
if (allowSilenced) { .orWhere('note.hasPoll = true')
qb.orWhere(`${key}Instance.isBlocked = false`); // not blocked .orWhere('note.fileIds != \'{}\'')));
} else {
qb.orWhere(new Brackets(qbb => qbb
.andWhere(`${key}Instance.isBlocked = false`) // not blocked
.andWhere(`${key}Instance.isSilenced = false`))); // not silenced
} }
@bindThis
public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
.leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
.andWhere(new Brackets(qb => {
qb
.orWhere(`"${key}Instance" IS NULL`) // local
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
if (excludeAuthor) { if (excludeAuthor) {
qb.orWhere(`note.userId = note.${key}Id`); // author qb.orWhere(`note.userId = note.${key}Id`); // author
} }
})); }));
}
if (!excludeAuthor) { if (!excludeAuthor) {
checkFor('user'); checkFor('user');
} }
checkFor('replyUser'); checkFor('replyUser');
checkFor('renoteUser'); checkFor('renoteUser');
return q;
}
@bindThis
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
if (!me) {
return q.andWhere('user.isSilenced = false');
}
return this
.leftJoinInstance(q, 'note.userInstance', 'userInstance')
.andWhere(new Brackets(qb => this
// case 1: we are following the user
.orFollowingUser(qb, ':meId', 'note.userId')
// case 2: user not silenced AND instance not silenced
.orWhere(new Brackets(qbb => qbb
.andWhere(new Brackets(qbbb => qbbb
.orWhere('"userInstance"."isSilenced" = false')
.orWhere('"userInstance" IS NULL')))
.andWhere('user.isSilenced = false')))))
.setParameters({ meId: me.id });
}
/**
* Left-joins an instance in to the query with a given alias and optional condition.
* These calls are de-duplicated - multiple uses of the same alias are skipped.
*/
@bindThis
public leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
// Skip if it's already joined, otherwise we'll get an error
if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) {
q.leftJoin(relation, alias, condition);
}
return q;
}
/**
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
}
/**
* Adds AND condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
}
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('1')
.andWhere(`following.followerId = ${followerProp}`)
.andWhere(`following.followeeId = ${followeeProp}`);
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
};
/**
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere');
}
/**
* Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere');
}
private excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('1')
.andWhere(`blocking.blockerId = ${blockerProp}`)
.andWhere(`blocking.blockeeId = ${blockeeProp}`);
return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters());
};
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude);
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude);
}
private excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('1')
.andWhere(`muting.muterId = ${muterProp}`)
.andWhere(`muting.muteeId = ${muteeProp}`);
if (exclude) {
mutingQuery.andWhere({ muteeId: Not(exclude.id) });
}
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
}
/**
* Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
.select('1')
.andWhere(`renote_muting.muterId = ${muterProp}`)
.andWhere(`renote_muting.muteeId = ${muteeProp}`);
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
};
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('1')
.andWhere(`user_profile.userId = ${muterProp}`)
.andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`);
return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters());
}
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('1')
.andWhere(`threadMuted.userId = ${muterProp}`)
.andWhere(`threadMuted.threadId = ${muteeProp}`);
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
} }
} }

View file

@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
mandatoryCW: null, mandatoryCW: null,
rejectQuotes: false, rejectQuotes: false,
allowUnsignedFetch: 'staff', allowUnsignedFetch: 'staff',
attributionDomains: [],
...override, ...override,
}; };
} }

View file

@ -613,6 +613,7 @@ export class ApRendererService {
enableRss: user.enableRss, enableRss: user.enableRss,
speakAsCat: user.speakAsCat, speakAsCat: user.speakAsCat,
attachment: attachment.length ? attachment : undefined, attachment: attachment.length ? attachment : undefined,
attributionDomains: user.attributionDomains,
}; };
if (user.movedToUri) { if (user.movedToUri) {

View file

@ -546,6 +546,10 @@ const extension_context_definition = {
featured: 'toot:featured', featured: 'toot:featured',
discoverable: 'toot:discoverable', discoverable: 'toot:discoverable',
indexable: 'toot:indexable', indexable: 'toot:indexable',
attributionDomains: {
'@id': 'toot:attributionDomains',
'@type': '@id',
},
// schema // schema
schema: 'http://schema.org#', schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue', PropertyValue: 'schema:PropertyValue',

View file

@ -445,6 +445,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
emojis, emojis,
attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [],
})) as MiRemoteUser; })) as MiRemoteUser;
let _description: string | null = null; let _description: string | null = null;
@ -628,6 +629,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic. // We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
hideOnlineStatus: person.hideOnlineStatus !== false, hideOnlineStatus: person.hideOnlineStatus !== false,
isExplorable: person.discoverable !== false, isExplorable: person.discoverable !== false,
attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [],
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))),
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>; } as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;

View file

@ -75,24 +75,31 @@ export function getOneApId(value: ApObject): string {
/** /**
* Get ActivityStreams Object id * Get ActivityStreams Object id
*/ */
export function getApId(value: string | IObject | [string | IObject]): string { export function getApId(source: string | IObject | [string | IObject]): string {
// eslint-disable-next-line no-param-reassign const value = getNullableApId(source);
value = fromTuple(value);
if (typeof value === 'string') return value; if (value == null) {
if (typeof value.id === 'string') return value.id; throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing or invalid id`);
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`); }
return value;
} }
/** /**
* Get ActivityStreams Object id, or null if not present * Get ActivityStreams Object id, or null if not present
*/ */
export function getNullableApId(value: string | IObject | [string | IObject]): string | null { export function getNullableApId(source: string | IObject | [string | IObject]): string | null {
// eslint-disable-next-line no-param-reassign const value: unknown = fromTuple(source);
value = fromTuple(value);
if (value != null) {
if (typeof value === 'string') {
return value;
}
if (typeof (value) === 'object' && 'id' in value && typeof (value.id) === 'string') {
return value.id;
}
}
if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id;
return null; return null;
} }
@ -265,6 +272,7 @@ export interface IActor extends IObject {
enableRss?: boolean; enableRss?: boolean;
listenbrainz?: string; listenbrainz?: string;
backgroundUrl?: string; backgroundUrl?: string;
attributionDomains?: string[];
} }
export const isCollection = (object: IObject): object is ICollection => export const isCollection = (object: IObject): object is ICollection =>

View file

@ -603,6 +603,7 @@ export class UserEntityService implements OnModuleInit {
enableRss: user.enableRss, enableRss: user.enableRss,
mandatoryCW: user.mandatoryCW, mandatoryCW: user.mandatoryCW,
rejectQuotes: user.rejectQuotes, rejectQuotes: user.rejectQuotes,
attributionDomains: user.attributionDomains,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false, speakAsCat: user.speakAsCat ?? false,
approved: user.approved, approved: user.approved,
@ -616,6 +617,7 @@ export class UserEntityService implements OnModuleInit {
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,
isSilenced: instance.isSilenced,
} : undefined) : undefined, } : undefined) : undefined,
followersCount: followersCount ?? 0, followersCount: followersCount ?? 0,
followingCount: followingCount ?? 0, followingCount: followingCount ?? 0,

View file

@ -6,7 +6,8 @@
import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
import { id } from './util/id.js'; import { id } from './util/id.js';
@Index('IDX_instance_host_key', { synchronize: false }) @Index('IDX_instance_host_key', { synchronize: false }) // ((lower(reverse("host"::text)) || '.'::text)
@Index('IDX_instance_host_filters', { synchronize: false }) // ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")
@Entity('instance') @Entity('instance')
export class MiInstance { export class MiInstance {
@PrimaryColumn(id()) @PrimaryColumn(id())

View file

@ -12,6 +12,8 @@ import { MiChannel } from './Channel.js';
import type { MiDriveFile } from './DriveFile.js'; import type { MiDriveFile } from './DriveFile.js';
@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id']) @Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id'])
@Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc)
@Index('IDX_note_for_timelines', { synchronize: false }) // (id desc, channelId, visibility, userHost)
@Entity('note') @Entity('note')
export class MiNote { export class MiNote {
@PrimaryColumn(id()) @PrimaryColumn(id())
@ -216,7 +218,6 @@ export class MiNote {
public processErrors: string[] | null; public processErrors: string[] | null;
//#region Denormalized fields //#region Denormalized fields
@Index()
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
comment: '[Denormalized]', comment: '[Denormalized]',

View file

@ -389,6 +389,12 @@ export class MiUser {
}) })
public allowUnsignedFetch: UserUnsignedFetchOption; public allowUnsignedFetch: UserUnsignedFetchOption;
@Column('varchar', {
name: 'attributionDomains',
length: 128, array: true, default: '{}',
})
public attributionDomains: string[];
constructor(data: Partial<MiUser>) { constructor(data: Partial<MiUser>) {
if (data == null) return; if (data == null) return;

View file

@ -200,6 +200,10 @@ export const packedUserLiteSchema = {
type: 'string', type: 'string',
nullable: true, optional: false, nullable: true, optional: false,
}, },
isSilenced: {
type: 'boolean',
nullable: false, optional: false,
},
}, },
}, },
emojis: { emojis: {
@ -236,6 +240,14 @@ export const packedUserLiteSchema = {
}, },
}, },
}, },
attributionDomains: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
},
},
}, },
} as const; } as const;

View file

@ -145,7 +145,10 @@ class MyCustomLogger implements Logger {
@bindThis @bindThis
private transformParameters(parameters?: any[]) { private transformParameters(parameters?: any[]) {
if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) { if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) {
return parameters.map(stringifyParameter); return parameters.reduce((params, p, i) => {
params[`$${i + 1}`] = stringifyParameter(p);
return params;
}, {} as Record<string, string>);
} }
return undefined; return undefined;
@ -158,7 +161,8 @@ class MyCustomLogger implements Logger {
const prefix = (this.props.printReplicationMode && queryRunner) const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] ` ? `[${queryRunner.getReplicationMode()}] `
: undefined; : undefined;
sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); const transformed = this.transformQueryLog(query, { prefix });
sqlLogger.debug(`Query run: ${transformed}`, this.transformParameters(parameters));
} }
@bindThis @bindThis

View file

@ -125,6 +125,14 @@ export class InboxProcessorService implements OnApplicationShutdown {
return `Old keyId is no longer supported. ${keyIdLower}`; return `Old keyId is no longer supported. ${keyIdLower}`;
} }
if (activity.actor as unknown == null || (Array.isArray(activity.actor) && activity.actor.length < 1)) {
return 'skip: activity has no actor';
}
if (typeof(activity.actor) !== 'string' && typeof(activity.actor) !== 'object') {
return `skip: activity actor has invalid type: ${typeof(activity.actor)}`;
}
const actorId = getApId(activity.actor);
// HTTP-Signature keyIdを元にDBから取得 // HTTP-Signature keyIdを元にDBから取得
let authUser: { let authUser: {
user: MiRemoteUser; user: MiRemoteUser;
@ -134,26 +142,26 @@ export class InboxProcessorService implements OnApplicationShutdown {
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得
if (authUser == null) { if (authUser == null) {
try { try {
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); authUser = await this.apDbResolverService.getAuthUserFromApId(actorId);
} catch (err) { } catch (err) {
// 対象が4xxならスキップ // 対象が4xxならスキップ
if (err instanceof StatusError) { if (err instanceof StatusError) {
if (!err.isRetryable) { if (!err.isRetryable) {
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${actorId} - ${err.statusCode}`);
} }
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`); throw new Error(`Error in actor ${actorId} - ${err.statusCode}`);
} }
} }
} }
// それでもわからなければ終了 // それでもわからなければ終了
if (authUser == null) { if (authUser == null) {
throw new Bull.UnrecoverableError(`skip: failed to resolve user ${getApId(activity.actor)}`); throw new Bull.UnrecoverableError(`skip: failed to resolve user ${actorId}`);
} }
// publicKey がなくても終了 // publicKey がなくても終了
if (authUser.key == null) { if (authUser.key == null) {
throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${getApId(activity.actor)}`); throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${actorId}`);
} }
// HTTP-Signatureの検証 // HTTP-Signatureの検証
@ -168,7 +176,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
} }
// また、signatureのsignerは、activity.actorと一致する必要がある // また、signatureのsignerは、activity.actorと一致する必要がある
if (!httpSignatureValidated || authUser.user.uri !== getApId(activity.actor)) { if (!httpSignatureValidated || authUser.user.uri !== actorId) {
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る // 一致しなくても、でもLD-Signatureがありそうならそっちも見る
const ldSignature = activity.signature; const ldSignature = activity.signature;
if (ldSignature) { if (ldSignature) {
@ -213,8 +221,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
activity.signature = ldSignature; activity.signature = ldSignature;
// もう一度actorチェック // もう一度actorチェック
if (authUser.user.uri !== activity.actor) { if (authUser.user.uri !== actorId) {
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorId})`);
} }
const ldHost = this.utilityService.extractDbHost(authUser.user.uri); const ldHost = this.utilityService.extractDbHost(authUser.user.uri);

View file

@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -75,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -106,13 +108,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
const query = this.notesRepository.createQueryBuilder('note') const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId)
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
@ -124,11 +127,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany(); const notes = await query.getMany();
if (sinceId != null && untilId == null) {
notes.sort((a, b) => a.id < b.id ? -1 : 1); process.nextTick(() => {
} else { this.activeUsersChart.read(me);
notes.sort((a, b) => a.id > b.id ? -1 : 1); });
}
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });

View file

@ -96,7 +96,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel); throw new ApiError(meta.errors.noSuchChannel);
} }
if (me) this.activeUsersChart.read(me); if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
}
if (!this.serverSettings.enableFanoutTimeline) { if (!this.serverSettings.enableFanoutTimeline) {
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me); return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
@ -133,8 +137,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
@ -142,22 +146,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) { if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
} }
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -263,6 +263,9 @@ export const paramDef = {
enum: userUnsignedFetchOptions, enum: userUnsignedFetchOptions,
nullable: false, nullable: false,
}, },
attributionDomains: { type: 'array', items: {
type: 'string',
} },
}, },
} as const; } as const;
@ -373,6 +376,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (ps.attributionDomains !== undefined) updates.attributionDomains = ps.attributionDomains;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
@ -663,7 +667,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// these two methods need to be kept in sync with // these two methods need to be kept in sync with
// `ApRendererService.renderPerson` // `ApRendererService.renderPerson`
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean { private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean {
const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore']; const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore', 'attributionDomains'];
for (const field of basicFields) { for (const field of basicFields) {
if ((field in newUser) && oldUser[field] !== newUser[field]) { if ((field in newUser) && oldUser[field] !== newUser[field]) {
return true; return true;

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: Marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm'; import type { NotesRepository } from '@/models/_.js';
import type { NotesRepository, MiMeta } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -56,9 +59,6 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -74,27 +73,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.btlDisabled); throw new ApiError(meta.errors.btlDisabled);
} }
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : undefined;
//#region Construct query //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'') .andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL') .andWhere('note.channelId IS NULL')
.andWhere('note.userHost IS NOT NULL') .andWhere('note.userHost IS NOT NULL')
.andWhere('userInstance.isBubbled = true') // This comes from generateBlockedHostQueryForNote below
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
// This subquery mess teaches postgres how to use the right indexes.
// Using WHERE or ON conditions causes a fallback to full sequence scan, which times out.
// Important: don't use a query builder here or TypeORM will get confused and stop quoting column names! (known, unfixed bug apparently)
query
.leftJoin('(select "host" from "instance" where "isBubbled" = true)', 'bubbleInstance', '"bubbleInstance"."host" = "note"."userHost"')
.andWhere('"bubbleInstance" IS NOT NULL');
this.queryService
.leftJoinInstance(query, 'note.userInstance', 'userInstance', '"userInstance"."host" = "bubbleInstance"."host"');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) {
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
if (!me) query.andWhere('user.requireSigninToViewContents = false'); this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
@ -103,34 +108,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) { if (!ps.withRenotes) {
query.andWhere(new Brackets(qb => qb this.queryService.generateExcludedRenotesQueryForNotes(query);
.orWhere('note.renoteId IS NULL') } else if (me) {
.orWhere('note.text IS NOT NULL') this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
.orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = false')
.orWhere('note.fileIds != \'{}\'')));
} }
//#endregion //#endregion
let timeline = await query.limit(ps.limit).getMany(); const timeline = await query.limit(ps.limit).getMany();
timeline = timeline.filter(note => {
if (note.user?.isSilenced) {
if (!me) return false;
if (!followings) return false;
if (note.userId !== me.id) {
return followings[note.userId];
}
}
return true;
});
process.nextTick(() => {
if (me) { if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
}
}); });
}
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}); });

View file

@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -76,8 +77,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private readonly noteEntityService: NoteEntityService,
private queryService: QueryService, private readonly queryService: QueryService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
@ -128,8 +130,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
// Exclude channel notes // Exclude channel notes
.andWhere({ channelId: IsNull() }) .andWhere({ channelId: IsNull() })
@ -157,11 +159,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Support pagination // Support pagination
this.queryService this.queryService
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.orderBy('note.id', 'DESC')
.take(ps.limit); .take(ps.limit);
// Query and return the next page // Query and return the next page
const notes = await query.getMany(); const notes = await query.getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(notes, me, { skipHide: true }); return await this.noteEntityService.packMany(notes, me, { skipHide: true });
}); });
} }

View file

@ -12,7 +12,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -68,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -76,8 +74,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled); throw new ApiError(meta.errors.gtlDisabled);
} }
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
//#region Construct query //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
@ -86,15 +82,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) { if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
} }
if (ps.withFiles) { if (ps.withFiles) {
@ -103,29 +98,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (ps.withRenotes === false) { if (!ps.withRenotes) {
query.andWhere(new Brackets(qb => { this.queryService.generateExcludedRenotesQueryForNotes(query);
qb.where('note.renoteId IS NULL'); } else if (me) {
qb.orWhere(new Brackets(qb => { this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
qb.where('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
} }
//#endregion //#endregion
let timeline = await query.limit(ps.limit).getMany(); const timeline = await query.limit(ps.limit).getMany();
timeline = timeline.filter(note => {
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
return true;
});
process.nextTick(() => {
if (me) { if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
}
}); });
}
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}); });

View file

@ -66,9 +66,6 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false },
@ -114,12 +111,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me); }, me);
process.nextTick(() => { process.nextTick(() => {
@ -178,12 +173,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit, limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me), }, me),
}); });
@ -199,12 +192,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null, untilId: string | null,
sinceId: string | null, sinceId: string | null,
limit: number, limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean, withFiles: boolean,
withReplies: boolean, withReplies: boolean,
withBots: boolean, withBots: boolean,
withRenotes: boolean,
}, me: MiLocalUser) { }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id); const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({ const followingChannels = await this.channelFollowingsRepository.find({
@ -227,8 +218,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
if (followingChannels.length > 0) { if (followingChannels.length > 0) {
const followingChannelIds = followingChannels.map(x => x.followeeId); const followingChannelIds = followingChannels.map(x => x.followeeId);
@ -255,45 +246,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -103,13 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me); }, me);
process.nextTick(() => {
if (me) { if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
}
}); });
}
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
} }
@ -136,14 +137,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me), }, me),
}); });
process.nextTick(() => {
if (me) { if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
}
}); });
}
return timeline; return timeline;
}); });
@ -156,26 +158,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: boolean, withFiles: boolean,
withReplies: boolean, withReplies: boolean,
withBots: boolean, withBots: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) { }, me: MiLocalUser | null) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId) ps.sinceId, ps.untilId)
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)') .andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.andWhere('note.userHost IS NULL')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) {
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (!ps.withReplies) { if (!ps.withReplies) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {
qb qb
@ -188,8 +202,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})); }));
} }
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
} }
} }

View file

@ -10,6 +10,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -57,43 +58,44 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const followingQuery = this.followingsRepository.createQueryBuilder('following') const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
.select('following.followeeId') ps.sinceId, ps.untilId)
.where('following.followerId = :followerId', { followerId: me.id }); .andWhere(new Brackets(qb => qb
.orWhere(':meId = ANY (note.mentions)')
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .orWhere(':meId = ANY (note.visibleUserIds)')))
.andWhere(new Brackets(qb => { .setParameters({ meId: me.id })
qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる
.where(':meIdAsList <@ note.mentions')
.orWhere(':meIdAsList <@ note.visibleUserIds');
}))
// Avoid scanning primary key index // Avoid scanning primary key index
.orderBy('CONCAT(note.id)', 'DESC') .orderBy('CONCAT(note.id)', 'DESC')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me); this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.visibility) { if (ps.visibility) {
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
} }
if (ps.following) { if (ps.following) {
query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }); this.queryService.andFollowingUser(query, ':meId', 'note.userId');
query.setParameters(followingQuery.getParameters());
} }
const mentions = await query.limit(ps.limit).getMany(); const mentions = await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(mentions, me); return await this.noteEntityService.packMany(mentions, me);
}); });
} }

View file

@ -96,7 +96,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
this.queryService.generateBlockedHostQueryForNote(query, undefined, false); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

View file

@ -49,9 +49,6 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withBots: { type: 'boolean', default: true }, withBots: { type: 'boolean', default: true },
@ -88,9 +85,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
withBots: ps.withBots, withBots: ps.withBots,
@ -131,9 +125,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit, limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
withBots: ps.withBots, withBots: ps.withBots,
@ -148,7 +139,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
} }
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) { private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id); const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({ const followingChannels = await this.channelFollowingsRepository.find({
where: { where: {
@ -161,8 +152,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
if (followees.length > 0 && followingChannels.length > 0) { if (followees.length > 0 && followingChannels.length > 0) {
// ユーザー・チャンネルともにフォローあり // ユーザー・チャンネルともにフォローあり
@ -212,47 +203,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (ps.withRenotes === false) {
query.andWhere('note.renoteId IS NULL');
}
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -57,9 +57,6 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withFiles: { withFiles: {
type: 'boolean', type: 'boolean',
@ -109,14 +106,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me); }, me);
process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
} }
@ -135,15 +131,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit, limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me), }, me),
}); });
process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
});
return timeline; return timeline;
}); });
@ -153,9 +148,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null, untilId: string | null,
sinceId: string | null, sinceId: string | null,
limit: number, limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean, withFiles: boolean,
withRenotes: boolean, withRenotes: boolean,
}, me: MiLocalUser) { }, me: MiLocalUser) {
@ -165,8 +157,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない .andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => {
@ -193,51 +185,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -74,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -101,11 +103,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.andWhere('(note.visibility = \'public\')') .andWhere('(note.visibility = \'public\')')
.orderBy('note.id', 'DESC')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
@ -113,7 +116,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany(); const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });

View file

@ -61,6 +61,21 @@ export default abstract class Channel {
return this.connection.subscriber; return this.connection.subscriber;
} }
/**
* Checks if a note is visible to the current user *excluding* blocks and mutes.
*/
protected isNoteVisibleToMe(note: Packed<'Note'>): boolean {
if (note.visibility === 'public') return true;
if (note.visibility === 'home') return true;
if (!this.user) return false;
if (this.user.id === note.userId) return true;
if (note.visibility === 'followers') {
return this.following[note.userId] != null;
}
if (!note.visibleUserIds) return false;
return note.visibleUserIds.includes(this.user.id);
}
/* /*
* *
*/ */
@ -69,7 +84,7 @@ export default abstract class Channel {
if (note.user.requireSigninToViewContents && !this.user) return true; if (note.user.requireSigninToViewContents && !this.user) return true;
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; if (isInstanceMuted(note, this.userMutedInstances) && !this.following[note.userId]) return true;
// 流れてきたNoteがミュートしているユーザーが関わる // 流れてきたNoteがミュートしているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
@ -82,6 +97,15 @@ export default abstract class Channel {
// If it's a boost (pure renote) then we need to check the target as well // If it's a boost (pure renote) then we need to check the target as well
if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true; if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true;
// Hide silenced notes
if (note.user.isSilenced || note.user.instance?.isSilenced) {
if (this.user == null) return true;
if (this.user.id === note.userId) return false;
if (this.following[note.userId] == null) return true;
}
// TODO muted threads
return false; return false;
} }

View file

@ -5,11 +5,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { MiMeta } from '@/models/Meta.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js'; import type { JsonObject } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
@ -22,10 +20,8 @@ class BubbleTimelineChannel extends Channel {
private withRenotes: boolean; private withRenotes: boolean;
private withFiles: boolean; private withFiles: boolean;
private withBots: boolean; private withBots: boolean;
private instance: MiMeta;
constructor( constructor(
private metaService: MetaService,
private roleService: RoleService, private roleService: RoleService,
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
noteEntityService: NoteEntityService, noteEntityService: NoteEntityService,
@ -44,7 +40,6 @@ class BubbleTimelineChannel extends Channel {
this.withRenotes = !!(params.withRenotes ?? true); this.withRenotes = !!(params.withRenotes ?? true);
this.withFiles = !!(params.withFiles ?? false); this.withFiles = !!(params.withFiles ?? false);
this.withBots = !!(params.withBots ?? true); this.withBots = !!(params.withBots ?? true);
this.instance = await this.metaService.fetch();
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -52,24 +47,37 @@ class BubbleTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return; if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) return; if (note.channelId != null) return;
if (note.user.host == null) return;
if (!this.utilityService.isBubbledHost(note.user.host)) return; if (!this.utilityService.isBubbledHost(note.user.host)) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (note.user.isSilenced) {
if (!this.user) return;
if (note.userId !== this.user.id && !this.following[note.userId]) return;
}
if (this.isNoteMutedOrBlocked(note)) return; if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);
@ -90,7 +98,6 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
public readonly kind = BubbleTimelineChannel.kind; public readonly kind = BubbleTimelineChannel.kind;
constructor( constructor(
private metaService: MetaService,
private roleService: RoleService, private roleService: RoleService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
@ -100,7 +107,6 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
@bindThis @bindThis
public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { public create(id: string, connection: Channel['connection']): BubbleTimelineChannel {
return new BubbleTimelineChannel( return new BubbleTimelineChannel(
this.metaService,
this.roleService, this.roleService,
this.utilityService, this.utilityService,
this.noteEntityService, this.noteEntityService,

View file

@ -48,20 +48,36 @@ class GlobalTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return; if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) return; if (note.channelId != null) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
if (this.isNoteMutedOrBlocked(note)) return; if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -50,37 +50,29 @@ class HomeTimelineChannel extends Channel {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return; if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} }
if (note.visibility === 'followers') { if (this.isNoteMutedOrBlocked(note)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return; if (!this.isNoteVisibleToMe(note)) return;
} else if (note.visibility === 'specified') {
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
if (note.reply) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
if (this.following[note.userId]?.withReplies) {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.isNoteVisibleToMe(reply)) return;
} else { if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
} }
} }
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
// 純粋なリノート(引用リノートでないリノート)の場合 // 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return; if (!this.withRenotes) return;
if (note.renote.reply) { if (note.renote.reply) {
const reply = note.renote.reply; const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.isNoteVisibleToMe(reply)) return;
} }
} }
if (this.isNoteMutedOrBlocked(note)) return;
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -67,34 +67,26 @@ class HybridTimelineChannel extends Channel {
(note.channelId != null && this.followingChannels.has(note.channelId)) (note.channelId != null && this.followingChannels.has(note.channelId))
)) return; )) return;
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
if (this.isNoteMutedOrBlocked(note)) return; if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.isNoteVisibleToMe(reply)) return;
} else { if (!this.following[note.userId]?.withReplies && !this.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
} }
} }
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
// 純粋なリノート(引用リノートでないリノート)の場合 // 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return; if (!this.withRenotes) return;
if (note.renote.reply) { if (note.renote.reply) {
const reply = note.renote.reply; const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.isNoteVisibleToMe(reply)) return;
} }
} }

View file

@ -50,28 +50,37 @@ class LocalTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return; if (!this.withBots && note.user.isBot) return;
if (note.user.host !== null) return; if (note.user.host !== null) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) return; if (note.channelId != null) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
// 関係ない返信は除外
if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (this.isNoteMutedOrBlocked(note)) return; if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
// 関係ない返信は除外
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -32,10 +32,12 @@ class MainChannel extends Channel {
switch (data.type) { switch (data.type) {
case 'notification': { case 'notification': {
// Ignore notifications from instances the user has muted // Ignore notifications from instances the user has muted
if (isUserFromMutedInstance(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; if (isUserFromMutedInstance(data.body, this.userMutedInstances)) return;
if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (data.body.note && data.body.note.isHidden) { if (data.body.note && data.body.note.isHidden) {
if (this.isNoteMutedOrBlocked(data.body.note)) return;
if (!this.isNoteVisibleToMe(data.body.id)) return;
const note = await this.noteEntityService.pack(data.body.note.id, this.user, { const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
detail: true, detail: true,
}); });
@ -44,9 +46,7 @@ class MainChannel extends Channel {
break; break;
} }
case 'mention': { case 'mention': {
if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; if (this.isNoteMutedOrBlocked(data.body)) return;
if (this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (data.body.isHidden) { if (data.body.isHidden) {
const note = await this.noteEntityService.pack(data.body.id, this.user, { const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true, detail: true,

View file

@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js'; import type { JsonObject } from '@/misc/json-value.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js'; import Channel, { type MiChannelService } from '../channel.js';
class RoleTimelineChannel extends Channel { class RoleTimelineChannel extends Channel {
@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel {
private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { private async onEvent(data: GlobalEvents['roleTimeline']['payload']) {
if (data.type === 'note') { if (data.type === 'note') {
const note = data.body; const note = data.body;
const isMe = this.user?.id === note.userId;
// TODO this should be cached
if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { if (!(await this.roleservice.isExplorable({ id: this.roleId }))) {
return; return;
} }
@ -48,6 +51,25 @@ class RoleTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return; if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js';
class UserListChannel extends Channel { class UserListChannel extends Channel {
public readonly chName = 'userList'; public readonly chName = 'userList';
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false as const; public static requireCredential = true as const;
public static kind = 'read:account';
private listId: string; private listId: string;
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout; private listUsersClock: NodeJS.Timeout;
@ -81,7 +82,7 @@ class UserListChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId; const isMe = this.user?.id === note.userId;
// チャンネル投稿は無視する // チャンネル投稿は無視する
if (note.channelId) return; if (note.channelId) return;
@ -90,26 +91,28 @@ class UserListChannel extends Channel {
if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (note.visibility === 'followers') { if (this.isNoteMutedOrBlocked(note)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return; if (!this.isNoteVisibleToMe(note)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
}
if (note.reply) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
if (this.membershipsMap[note.userId]?.withReplies) {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; if (!this.isNoteVisibleToMe(reply)) return;
} else { if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
} }
} }
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; // 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (this.isNoteMutedOrBlocked(note)) return; if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);
@ -128,7 +131,7 @@ class UserListChannel extends Channel {
} }
@Injectable() @Injectable()
export class UserListChannelService implements MiChannelService<false> { export class UserListChannelService implements MiChannelService<true> {
public readonly shouldShare = UserListChannel.shouldShare; public readonly shouldShare = UserListChannel.shouldShare;
public readonly requireCredential = UserListChannel.requireCredential; public readonly requireCredential = UserListChannel.requireCredential;
public readonly kind = UserListChannel.kind; public readonly kind = UserListChannel.kind;

View file

@ -20,6 +20,7 @@ import { RedisKVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import type { MiAccessToken, NotesRepository } from '@/models/_.js'; import type { MiAccessToken, NotesRepository } from '@/models/_.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js';
@ -30,10 +31,14 @@ import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-
import type { MiLocalUser } from '@/models/User.js'; import type { MiLocalUser } from '@/models/User.js';
import { getIpHash } from '@/misc/get-ip-hash.js'; import { getIpHash } from '@/misc/get-ip-hash.js';
import { isRetryableError } from '@/misc/is-retryable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js';
import * as Acct from '@/misc/acct.js';
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & { export type LocalSummalyResult = SummalyResult & {
haveNoteLocally?: boolean; haveNoteLocally?: boolean;
linkAttribution?: {
userId: string,
}
}; };
// Increment this to invalidate cached previews after a major change. // Increment this to invalidate cached previews after a major change.
@ -82,6 +87,7 @@ export class UrlPreviewService {
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
private readonly apUtilityService: ApUtilityService, private readonly apUtilityService: ApUtilityService,
private readonly apDbResolverService: ApDbResolverService, private readonly apDbResolverService: ApDbResolverService,
private readonly remoteUserResolveService: RemoteUserResolveService,
private readonly apRequestService: ApRequestService, private readonly apRequestService: ApRequestService,
private readonly systemAccountService: SystemAccountService, private readonly systemAccountService: SystemAccountService,
private readonly apNoteService: ApNoteService, private readonly apNoteService: ApNoteService,
@ -206,6 +212,8 @@ export class UrlPreviewService {
} }
} }
await this.validateLinkAttribution(summary);
// Await this to avoid hammering redis when a bunch of URLs are fetched at once // Await this to avoid hammering redis when a bunch of URLs are fetched at once
await this.previewCache.set(cacheKey, summary); await this.previewCache.set(cacheKey, summary);
@ -426,6 +434,30 @@ export class UrlPreviewService {
} }
} }
private async validateLinkAttribution(summary: LocalSummalyResult) {
if (!summary.fediverseCreator) return;
if (!URL.canParse(summary.url)) return;
const url = URL.parse(summary.url);
const acct = Acct.parse(summary.fediverseCreator);
if (acct.host?.toLowerCase() === this.config.host) {
acct.host = null;
}
try {
const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host);
const attributionDomains = user.attributionDomains;
if (attributionDomains.some(x => `.${url?.host.toLowerCase()}`.endsWith(`.${x}`))) {
summary.linkAttribution = {
userId: user.id,
};
}
} catch {
this.logger.debug('User not found: ' + summary.fediverseCreator);
}
}
// Adapted from ApiCallService // Adapted from ApiCallService
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> { private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
const [user, app] = auth; const [user, app] = auth;

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: outvi and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(() => {
resolve();
}, ms);
});
}
export async function retryOnThrottled<T>(f: () => Promise<T>, retryCount = 5): Promise<T> {
let lastError;
for (let i = 0; i < Math.min(retryCount, 1); i++) {
try {
return await f();
} catch (err: any) {
// RATE_LIMIT_EXCEEDED
if (typeof err === 'object' && err?.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') {
lastError = err;
await sleep(err?.info?.fullResetMs ?? 1000);
} else {
throw err;
}
}
}
throw lastError;
}

View file

@ -65,6 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</footer> </footer>
</article> </article>
</component> </component>
<I18n v-if="attributionUser" :src="i18n.ts.writtenBy" :class="$style.linkAttribution" tag="p">
<template #user>
<MkA v-user-preview="attributionUser.id" :to="userPage(attributionUser)">
<MkAvatar :class="$style.linkAttributionIcon" :user="attributionUser"/>
<MkUserName :user="attributionUser" style="color: var(--MI_THEME-accent)"/>
</MkA>
</template>
</I18n>
<p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p>
<template v-if="showActions"> <template v-if="showActions">
<div v-if="tweetId" :class="$style.action"> <div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true"> <MkButton :small="true" inline @click="tweetExpanded = true">
@ -106,6 +117,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import { warningExternalWebsite } from '@/utility/warning-external-website.js';
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { $i } from '@/i'; import { $i } from '@/i';
import { userPage } from '@/filters/user.js';
type SummalyResult = Awaited<ReturnType<typeof summaly>>; type SummalyResult = Awaited<ReturnType<typeof summaly>>;
@ -146,6 +158,10 @@ const player = ref<SummalyResult['player']>({
height: null, height: null,
allow: [], allow: [],
}); });
const linkAttribution = ref<{
userId: string,
} | null>(null);
const attributionUser = ref<Misskey.entities.User | null>(null);
const playerEnabled = ref(false); const playerEnabled = ref(false);
const tweetId = ref<string | null>(null); const tweetId = ref<string | null>(null);
const tweetExpanded = ref(props.detail); const tweetExpanded = ref(props.detail);
@ -221,7 +237,12 @@ function refresh(withFetch = false) {
return res.json(); return res.json();
}) })
.then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => { .then(async (info: SummalyResult & {
haveNoteLocally?: boolean,
linkAttribution?: {
userId: string,
}
} | null) => {
unknownUrl.value = info == null; unknownUrl.value = info == null;
title.value = info?.title ?? null; title.value = info?.title ?? null;
description.value = info?.description ?? null; description.value = info?.description ?? null;
@ -236,6 +257,16 @@ function refresh(withFetch = false) {
}; };
sensitive.value = info?.sensitive ?? false; sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null; activityPub.value = info?.activityPub ?? null;
linkAttribution.value = info?.linkAttribution ?? null;
if (linkAttribution.value) {
try {
const response = await misskeyApi('users/show', { userId: linkAttribution.value.userId });
attributionUser.value = response;
} catch {
// makes the loading ellipsis vanish.
linkAttribution.value = null;
}
}
theNote.value = null; theNote.value = null;
if (info?.haveNoteLocally) { if (info?.haveNoteLocally) {
@ -395,6 +426,28 @@ refresh();
vertical-align: top; vertical-align: top;
} }
.linkAttributionIcon {
display: inline-block;
width: 16px;
height: 16px;
margin-left: 0.25em;
margin-right: 0.25em;
vertical-align: middle;
border-radius: 50%;
* {
border-radius: 4px;
}
}
.linkAttribution {
width: 100%;
font-size: 0.8em;
display: inline-block;
margin: auto;
padding-top: 0.5em;
text-align: right;
}
.action { .action {
display: flex; display: flex;
gap: 6px; gap: 6px;

View file

@ -11,8 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { retryOnThrottled } from '@@/js/retry-on-throttled.js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
@ -20,16 +21,25 @@ import { misskeyApi } from '@/utility/misskey-api.js';
const props = defineProps<{ const props = defineProps<{
block: Misskey.entities.PageBlock, block: Misskey.entities.PageBlock,
page: Misskey.entities.Page, page: Misskey.entities.Page,
index: number;
}>(); }>();
const note = ref<Misskey.entities.Note | null>(null); const note = ref<Misskey.entities.Note | null>(null);
// eslint-disable-next-line id-denylist
let timeoutId: ReturnType<typeof window.setTimeout> | null = null;
onMounted(() => { onMounted(() => {
if (props.block.note == null) return; if (props.block.note == null) return;
misskeyApi('notes/show', { noteId: props.block.note }) timeoutId = window.setTimeout(async () => {
.then(result => { note.value = await retryOnThrottled(() => misskeyApi('notes/show', { noteId: props.block.note }));
note.value = result; }, 500 * props.index); // rate limit is 2 reqs per sec
}); });
onUnmounted(() => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
}); });
</script> </script>

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps"> <div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps">
<XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/> <XBlock v-for="(child, index) in page.content" :key="child.id" :index="index" :page="page" :block="child" :h="2"/>
</div> </div>
</template> </template>

View file

@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable vue/no-mutating-props */ /* eslint-disable vue/no-mutating-props */
import { watch, ref } from 'vue'; import { watch, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { retryOnThrottled } from '@@/js/retry-on-throttled.js';
import XContainer from '../page-editor.container.vue'; import XContainer from '../page-editor.container.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
@ -35,6 +36,7 @@ import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
modelValue: Misskey.entities.PageBlock & { type: 'note' }; modelValue: Misskey.entities.PageBlock & { type: 'note' };
index: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -58,7 +60,13 @@ watch(id, async () => {
...props.modelValue, ...props.modelValue,
note: id.value, note: id.value,
}); });
note.value = await misskeyApi('notes/show', { noteId: id.value }); const timeoutId = window.setTimeout(async () => {
note.value = await retryOnThrottled(() => misskeyApi('notes/show', { noteId: id.value }));
}, 500 * props.index); // rate limit is 2 reqs per sec
return () => {
window.clearTimeout(timeoutId);
};
}, { }, {
immediate: true, immediate: true,
}); });

View file

@ -5,10 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)"> <Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)">
<template #item="{element}"> <template #item="{element, index}">
<div :class="$style.item"> <div :class="$style.item">
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
<component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/> <component
:is="getComponent(element.type)"
:modelValue="element"
:index="index"
@update:modelValue="updateItem"
@remove="() => removeItem(element)"
/>
</div> </div>
</template> </template>
</Sortable> </Sortable>

View file

@ -15,36 +15,50 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from 'vue'; import { ref, watch, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
const $i = ensureSignin(); const $i = ensureSignin();
const instanceMutes = ref($i.mutedInstances.join('\n')); const instanceMutes = ref($i.mutedInstances.join('\n'));
const domainArray = computed(() => {
return instanceMutes.value
.trim().split('\n')
.map(el => el.trim().toLowerCase())
.filter(el => el);
});
const changed = ref(false); const changed = ref(false);
async function save() { async function save() {
let mutes = instanceMutes.value // checks for a full line without whitespace.
.trim().split('\n') if (!domainArray.value.every(d => /^\S+$/.test(d))) {
.map(el => el.trim()) os.alert({
.filter(el => el); type: 'error',
title: i18n.ts.invalidValue,
await misskeyApi('i/update', {
mutedInstances: mutes,
}); });
return;
changed.value = false;
// Refresh filtered list to signal to the user how they've been saved
instanceMutes.value = mutes.join('\n');
} }
watch(instanceMutes, () => { await misskeyApi('i/update', {
mutedInstances: domainArray.value,
});
// Refresh filtered list to signal to the user how they've been saved
instanceMutes.value = domainArray.value.join('\n');
changed.value = false;
}
watch(domainArray, (newArray, oldArray) => {
// compare arrays
if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) {
changed.value = true; changed.value = true;
}
}); });
</script> </script>

View file

@ -0,0 +1,67 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkTextarea v-model="attributionDomains">
<template #label><SearchLabel>{{ i18n.ts.attributionDomains }}</SearchLabel></template>
<template #caption>
{{ i18n.ts.attributionDomainsDescription }}
<br/>
<Mfm :text="tutorialTag"/>
</template>
</MkTextarea>
<MkButton primary :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { host as hostRaw } from '@@/js/config.js';
import { toUnicode } from 'punycode.js';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
const $i = ensureSignin();
const attributionDomains = ref($i.attributionDomains.join('\n'));
const domainArray = computed(() => {
return attributionDomains.value
.trim().split('\n')
.map(el => el.trim().toLowerCase())
.filter(el => el);
});
const changed = ref(false);
const tutorialTag = '`<meta name="fediverse:creator" content="' + $i.username + '@' + toUnicode(hostRaw) + '" />`';
async function save() {
// checks for a full line without whitespace.
if (!domainArray.value.every(d => /^\S+$/.test(d))) {
os.alert({
type: 'error',
title: i18n.ts.invalidValue,
});
return;
}
await misskeyApi('i/update', {
attributionDomains: domainArray.value,
});
// Refresh filtered list to signal to the user how they've been saved
attributionDomains.value = domainArray.value.join('\n');
changed.value = false;
}
watch(domainArray, (newArray, oldArray) => {
// compare arrays
if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) {
changed.value = true;
}
});
</script>

View file

@ -163,6 +163,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.flagAsBotDescription }}</template> <template #caption>{{ i18n.ts.flagAsBotDescription }}</template>
</MkSwitch> </MkSwitch>
</SearchMarker> </SearchMarker>
<SearchMarker
:label="i18n.ts.attributionDomains"
:keywords="['attribution', 'domains', 'preview', 'url']"
>
<AttributionDomainsSettings/>
</SearchMarker>
</div> </div>
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
@ -172,6 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue';
import AttributionDomainsSettings from './profile.attribution-domains-setting.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';

View file

@ -598,6 +598,10 @@ roleAutomatic: "automatic"
translationTimeoutLabel: "Translation timeout" translationTimeoutLabel: "Translation timeout"
translationTimeoutCaption: "Timeout in milliseconds for translation API requests." translationTimeoutCaption: "Timeout in milliseconds for translation API requests."
attributionDomains: "Attribution Domains"
attributionDomainsDescription: "A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:"
writtenBy: "Written by {user}"
followingPub: "Following (Pub)" followingPub: "Following (Pub)"
followersSub: "Followers (Sub)" followersSub: "Followers (Sub)"
wellKnownResources: "Well-known resources" wellKnownResources: "Well-known resources"

View file

@ -7,3 +7,6 @@ openRemoteProfile: "Abrir perfil remoto"
allowClickingNotifications: "Permitir clicar em notificações" allowClickingNotifications: "Permitir clicar em notificações"
pinnedOnly: "Fixado" pinnedOnly: "Fixado"
blockingYou: "Bloqueando você" blockingYou: "Bloqueando você"
attributionDomains: "Domínios de Atribuição"
attributionDomainsDescription: "Uma lista de domínios cujo conteúdo pode ser atribuído a você em prévias de link, separadas por linha. Qualquer subdomínio também será válido. O código seguinte precisa estar presente na página:"
writtenBy: "Escrito por {user}"