Merge Sharkey Develop
This commit is contained in:
parent
09f51889ff
commit
6e64e3d38f
55 changed files with 1091 additions and 572 deletions
12
locales/index.d.ts
vendored
12
locales/index.d.ts
vendored
|
@ -13157,6 +13157,18 @@ export interface Locale extends ILocale {
|
|||
* Timeout in milliseconds for translation API requests.
|
||||
*/
|
||||
"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)
|
||||
*/
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "quollkey",
|
||||
"version": "2025.6.0-Q",
|
||||
"version": "2025.6.1-dev",
|
||||
"codename": "quoll",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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") `);
|
||||
}
|
||||
}
|
|
@ -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)`);
|
||||
}
|
||||
}
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -4,13 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets, ObjectLiteral } from 'typeorm';
|
||||
import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.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 { IdService } from '@/core/IdService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class QueryService {
|
||||
|
@ -36,6 +37,9 @@ export class QueryService {
|
|||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private readonly instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
|
@ -72,215 +76,349 @@ export class QueryService {
|
|||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
@bindThis
|
||||
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
// 投稿の作者にブロックされていない かつ
|
||||
// 投稿の返信先の作者にブロックされていない かつ
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
return this
|
||||
.andNotBlockingUser(q, 'note.userId', ':meId')
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotBlockingUser(qb, 'note.replyUserId', ':meId')
|
||||
.orWhere('note.replyUserId IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
|
||||
.orWhere('note.renoteUserId IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockeeId')
|
||||
.where('blocking.blockerId = :blockerId', { blockerId: 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());
|
||||
public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
this.andNotBlockingUser(q, ':meId', 'user.id');
|
||||
this.andNotBlockingUser(q, 'user.id', ':me.id');
|
||||
return q.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
.select('threadMuted.threadId')
|
||||
.where('threadMuted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return this
|
||||
.andNotMutingThread(q, ':meId', 'note.id')
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingThread(qb, ':meId', 'note.threadId')
|
||||
.orWhere('note.threadId IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
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 });
|
||||
|
||||
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
// 投稿の作者をミュートしていない かつ
|
||||
// 投稿の返信先の作者をミュートしていない かつ
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
return this
|
||||
.andNotMutingUser(q, ':meId', 'note.userId', exclude)
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
|
||||
.orWhere('note.replyUserId IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
|
||||
.orWhere('note.renoteUserId IS NULL')))
|
||||
// TODO exclude should also pass a host to skip these instances
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.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());
|
||||
.andWhere(new Brackets(qb => this
|
||||
.andNotMutingInstance(qb, ':meId', 'note.userHost')
|
||||
.orWhere('note.userHost IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
|
||||
.orWhere('note.replyUserHost IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
|
||||
.orWhere('note.renoteUserHost IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return this
|
||||
.andNotMutingUser(q, ':meId', 'user.id')
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
// 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
|
||||
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.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
return q.andWhere(new Brackets(qb => {
|
||||
// Public post
|
||||
qb.orWhere('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
if (me != null) {
|
||||
qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId')
|
||||
.orWhere(':meIdAsList <@ note.mentions');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
// My post
|
||||
.orWhere(':meId = note.userId')
|
||||
// Reply to me
|
||||
.orWhere(':meId = note.replyUserId')
|
||||
// DM to me
|
||||
.orWhere(':meId = ANY (note.visibleUserIds)')
|
||||
// Mentions me
|
||||
.orWhere(':meId = ANY (note.mentions)')
|
||||
// Followers-only post
|
||||
.orWhere(new Brackets(qb => this
|
||||
.andFollowingUser(qb, ':meId', 'note.userId')
|
||||
.andWhere('note.visibility = \'followers\'')));
|
||||
|
||||
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||
}
|
||||
q.setParameters({ meId: me.id });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
||||
.select('renote_muting.muteeId')
|
||||
.where('renote_muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return q
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingRenote(qb, ':meId', 'note.userId')
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL')
|
||||
.orWhere('note.cw IS NOT NULL')
|
||||
.orWhere('note.replyId IS NOT NULL')
|
||||
.orWhere('note.hasPoll = false')
|
||||
.orWhere('note.fileIds != \'{}\'')
|
||||
.orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
.orWhere('note.hasPoll = true')
|
||||
.orWhere('note.fileIds != \'{}\'')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean, allowSilenced = true): void {
|
||||
function checkFor(key: 'user' | 'replyUser' | 'renoteUser') {
|
||||
q.leftJoin(`note.${key}Instance`, `${key}Instance`);
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user
|
||||
.orWhere(`note.${key}Host IS NULL`); // local
|
||||
public generateExcludedRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>): SelectQueryBuilder<E> {
|
||||
return q
|
||||
.andWhere(new Brackets(qb => qb
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL')
|
||||
.orWhere('note.cw IS NOT NULL')
|
||||
.orWhere('note.replyId IS NOT NULL')
|
||||
.orWhere('note.hasPoll = true')
|
||||
.orWhere('note.fileIds != \'{}\'')));
|
||||
}
|
||||
|
||||
if (allowSilenced) {
|
||||
qb.orWhere(`${key}Instance.isBlocked = false`); // not blocked
|
||||
} 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) {
|
||||
qb.orWhere(`note.userId = note.${key}Id`); // author
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (!excludeAuthor) {
|
||||
checkFor('user');
|
||||
}
|
||||
checkFor('replyUser');
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
mandatoryCW: null,
|
||||
rejectQuotes: false,
|
||||
allowUnsignedFetch: 'staff',
|
||||
attributionDomains: [],
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -613,6 +613,7 @@ export class ApRendererService {
|
|||
enableRss: user.enableRss,
|
||||
speakAsCat: user.speakAsCat,
|
||||
attachment: attachment.length ? attachment : undefined,
|
||||
attributionDomains: user.attributionDomains,
|
||||
};
|
||||
|
||||
if (user.movedToUri) {
|
||||
|
|
|
@ -546,6 +546,10 @@ const extension_context_definition = {
|
|||
featured: 'toot:featured',
|
||||
discoverable: 'toot:discoverable',
|
||||
indexable: 'toot:indexable',
|
||||
attributionDomains: {
|
||||
'@id': 'toot:attributionDomains',
|
||||
'@type': '@id',
|
||||
},
|
||||
// schema
|
||||
schema: 'http://schema.org#',
|
||||
PropertyValue: 'schema:PropertyValue',
|
||||
|
|
|
@ -445,6 +445,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||
emojis,
|
||||
attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [],
|
||||
})) as MiRemoteUser;
|
||||
|
||||
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.
|
||||
hideOnlineStatus: person.hideOnlineStatus !== 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(() => ({}))),
|
||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
|
||||
|
|
|
@ -75,24 +75,31 @@ export function getOneApId(value: ApObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object id
|
||||
*/
|
||||
export function getApId(value: string | IObject | [string | IObject]): string {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
export function getApId(source: string | IObject | [string | IObject]): string {
|
||||
const value = getNullableApId(source);
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`);
|
||||
if (value == null) {
|
||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing or invalid id`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ActivityStreams Object id, or null if not present
|
||||
*/
|
||||
export function getNullableApId(value: string | IObject | [string | IObject]): string | null {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
export function getNullableApId(source: string | IObject | [string | IObject]): string | null {
|
||||
const value: unknown = fromTuple(source);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -265,6 +272,7 @@ export interface IActor extends IObject {
|
|||
enableRss?: boolean;
|
||||
listenbrainz?: string;
|
||||
backgroundUrl?: string;
|
||||
attributionDomains?: string[];
|
||||
}
|
||||
|
||||
export const isCollection = (object: IObject): object is ICollection =>
|
||||
|
|
|
@ -603,6 +603,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
enableRss: user.enableRss,
|
||||
mandatoryCW: user.mandatoryCW,
|
||||
rejectQuotes: user.rejectQuotes,
|
||||
attributionDomains: user.attributionDomains,
|
||||
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
||||
speakAsCat: user.speakAsCat ?? false,
|
||||
approved: user.approved,
|
||||
|
@ -616,6 +617,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
iconUrl: instance.iconUrl,
|
||||
faviconUrl: instance.faviconUrl,
|
||||
themeColor: instance.themeColor,
|
||||
isSilenced: instance.isSilenced,
|
||||
} : undefined) : undefined,
|
||||
followersCount: followersCount ?? 0,
|
||||
followingCount: followingCount ?? 0,
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
|
||||
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')
|
||||
export class MiInstance {
|
||||
@PrimaryColumn(id())
|
||||
|
|
|
@ -12,6 +12,8 @@ import { MiChannel } from './Channel.js';
|
|||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
@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')
|
||||
export class MiNote {
|
||||
@PrimaryColumn(id())
|
||||
|
@ -216,7 +218,6 @@ export class MiNote {
|
|||
public processErrors: string[] | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
|
|
|
@ -389,6 +389,12 @@ export class MiUser {
|
|||
})
|
||||
public allowUnsignedFetch: UserUnsignedFetchOption;
|
||||
|
||||
@Column('varchar', {
|
||||
name: 'attributionDomains',
|
||||
length: 128, array: true, default: '{}',
|
||||
})
|
||||
public attributionDomains: string[];
|
||||
|
||||
constructor(data: Partial<MiUser>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
|
@ -200,6 +200,10 @@ export const packedUserLiteSchema = {
|
|||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
isSilenced: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
emojis: {
|
||||
|
@ -236,6 +240,14 @@ export const packedUserLiteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
attributionDomains: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -145,7 +145,10 @@ class MyCustomLogger implements Logger {
|
|||
@bindThis
|
||||
private transformParameters(parameters?: any[]) {
|
||||
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;
|
||||
|
@ -158,7 +161,8 @@ class MyCustomLogger implements Logger {
|
|||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: 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
|
||||
|
|
|
@ -125,6 +125,14 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
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から取得
|
||||
let authUser: {
|
||||
user: MiRemoteUser;
|
||||
|
@ -134,26 +142,26 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得
|
||||
if (authUser == null) {
|
||||
try {
|
||||
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor));
|
||||
authUser = await this.apDbResolverService.getAuthUserFromApId(actorId);
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
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) {
|
||||
throw new Bull.UnrecoverableError(`skip: failed to resolve user ${getApId(activity.actor)}`);
|
||||
throw new Bull.UnrecoverableError(`skip: failed to resolve user ${actorId}`);
|
||||
}
|
||||
|
||||
// publicKey がなくても終了
|
||||
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の検証
|
||||
|
@ -168,7 +176,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// また、signatureのsignerは、activity.actorと一致する必要がある
|
||||
if (!httpSignatureValidated || authUser.user.uri !== getApId(activity.actor)) {
|
||||
if (!httpSignatureValidated || authUser.user.uri !== actorId) {
|
||||
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る
|
||||
const ldSignature = activity.signature;
|
||||
if (ldSignature) {
|
||||
|
@ -213,8 +221,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
activity.signature = ldSignature;
|
||||
|
||||
// もう一度actorチェック
|
||||
if (authUser.user.uri !== activity.actor) {
|
||||
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
|
||||
if (authUser.user.uri !== actorId) {
|
||||
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorId})`);
|
||||
}
|
||||
|
||||
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
|
||||
|
|
|
@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -75,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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 [];
|
||||
}
|
||||
|
||||
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 })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
|
||||
// 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);
|
||||
|
||||
const notes = await query.getMany();
|
||||
if (sinceId != null && untilId == null) {
|
||||
notes.sort((a, b) => a.id < b.id ? -1 : 1);
|
||||
} else {
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
|
|
|
@ -96,7 +96,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
if (me) this.activeUsersChart.read(me);
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -133,8 +137,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
|
@ -142,22 +146,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(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) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
|
|
@ -263,6 +263,9 @@ export const paramDef = {
|
|||
enum: userUnsignedFetchOptions,
|
||||
nullable: false,
|
||||
},
|
||||
attributionDomains: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
} 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.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.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
|
||||
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
|
||||
// `ApRendererService.renderPerson`
|
||||
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) {
|
||||
if ((field in newUser) && oldUser[field] !== newUser[field]) {
|
||||
return true;
|
||||
|
|
|
@ -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 { Brackets } from 'typeorm';
|
||||
import type { NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -56,9 +59,6 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private serverSettings: MiMeta,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
|
@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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);
|
||||
}
|
||||
|
||||
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : undefined;
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userHost IS NOT NULL')
|
||||
.andWhere('userInstance.isBubbled = true') // This comes from generateBlockedHostQueryForNote below
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.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);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
if (!me) query.andWhere('user.requireSigninToViewContents = false');
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
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.withRenotes) {
|
||||
query.andWhere(new Brackets(qb => qb
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL')
|
||||
.orWhere('note.cw IS NOT NULL')
|
||||
.orWhere('note.replyId IS NOT NULL')
|
||||
.orWhere('note.hasPoll = false')
|
||||
.orWhere('note.fileIds != \'{}\'')));
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -76,8 +77,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly noteEntityService: NoteEntityService,
|
||||
private readonly queryService: QueryService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
|
||||
|
||||
// Exclude channel notes
|
||||
.andWhere({ channelId: IsNull() })
|
||||
|
@ -157,11 +159,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// Support pagination
|
||||
this.queryService
|
||||
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.orderBy('note.id', 'DESC')
|
||||
.take(ps.limit);
|
||||
|
||||
// Query and return the next page
|
||||
const notes = await query.getMany();
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me, { skipHide: true });
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -68,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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);
|
||||
}
|
||||
|
||||
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
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')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
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.withRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('note.renoteId IS NULL');
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb.where('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
}));
|
||||
}));
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
|
|
|
@ -66,9 +66,6 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
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 },
|
||||
withRenotes: { type: 'boolean', default: true },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
|
@ -114,12 +111,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
process.nextTick(() => {
|
||||
|
@ -178,12 +173,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me),
|
||||
});
|
||||
|
||||
|
@ -199,12 +192,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId: string | null,
|
||||
sinceId: string | null,
|
||||
limit: number,
|
||||
includeMyRenotes: boolean,
|
||||
includeRenotedMyNotes: boolean,
|
||||
includeLocalRenotes: boolean,
|
||||
withFiles: boolean,
|
||||
withReplies: boolean,
|
||||
withBots: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
|
@ -227,8 +218,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
if (followingChannels.length > 0) {
|
||||
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.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(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) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
|
|
@ -103,13 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(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,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me),
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return timeline;
|
||||
});
|
||||
|
@ -156,26 +158,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: boolean,
|
||||
withReplies: boolean,
|
||||
withBots: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
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')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
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) {
|
||||
query.andWhere(new Brackets(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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -57,43 +58,44 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる
|
||||
.where(':meIdAsList <@ note.mentions')
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds');
|
||||
}))
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => qb
|
||||
.orWhere(':meId = ANY (note.mentions)')
|
||||
.orWhere(':meId = ANY (note.visibleUserIds)')))
|
||||
.setParameters({ meId: me.id })
|
||||
// Avoid scanning primary key index
|
||||
.orderBy('CONCAT(note.id)', 'DESC')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.visibility) {
|
||||
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
|
||||
}
|
||||
|
||||
if (ps.following) {
|
||||
query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id });
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
this.queryService.andFollowingUser(query, ':meId', 'note.userId');
|
||||
}
|
||||
|
||||
const mentions = await query.limit(ps.limit).getMany();
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(mentions, me);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -96,7 +96,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
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.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
|
|
@ -49,9 +49,6 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
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 },
|
||||
withRenotes: { type: 'boolean', default: true },
|
||||
withBots: { type: 'boolean', default: true },
|
||||
|
@ -88,9 +85,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
withBots: ps.withBots,
|
||||
|
@ -131,9 +125,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
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 followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
|
@ -161,8 +152,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
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.generateMutedUserQueryForNotes(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) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere('note.renoteId IS NULL');
|
||||
}
|
||||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
|
|
@ -57,9 +57,6 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
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 },
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
|
@ -109,14 +106,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
this.activeUsersChart.read(me);
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
@ -135,15 +131,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me),
|
||||
});
|
||||
|
||||
this.activeUsersChart.read(me);
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return timeline;
|
||||
});
|
||||
|
@ -153,9 +148,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId: string | null,
|
||||
sinceId: string | null,
|
||||
limit: number,
|
||||
includeMyRenotes: boolean,
|
||||
includeRenotedMyNotes: boolean,
|
||||
includeLocalRenotes: boolean,
|
||||
withFiles: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser) {
|
||||
|
@ -165,8 +157,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
|
||||
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
|
||||
.andWhere('note.channelId IS NULL') // チャンネルノートではない
|
||||
.andWhere(new Brackets(qb => {
|
||||
|
@ -193,51 +185,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(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) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
|
|
@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -74,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.andWhere('(note.visibility = \'public\')')
|
||||
.orderBy('note.id', 'DESC')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
|
@ -113,7 +116,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
@ -61,6 +61,21 @@ export default abstract class Channel {
|
|||
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;
|
||||
|
||||
// 流れてきた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がミュートしているユーザーが関わる
|
||||
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 (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;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,11 +5,9 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
@ -22,10 +20,8 @@ class BubbleTimelineChannel extends Channel {
|
|||
private withRenotes: boolean;
|
||||
private withFiles: boolean;
|
||||
private withBots: boolean;
|
||||
private instance: MiMeta;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private readonly utilityService: UtilityService,
|
||||
noteEntityService: NoteEntityService,
|
||||
|
@ -44,7 +40,6 @@ class BubbleTimelineChannel extends Channel {
|
|||
this.withRenotes = !!(params.withRenotes ?? true);
|
||||
this.withFiles = !!(params.withFiles ?? false);
|
||||
this.withBots = !!(params.withBots ?? true);
|
||||
this.instance = await this.metaService.fetch();
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
|
@ -52,24 +47,37 @@ class BubbleTimelineChannel extends Channel {
|
|||
|
||||
@bindThis
|
||||
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.withBots && note.user.isBot) return;
|
||||
|
||||
if (note.visibility !== 'public') return;
|
||||
if (note.channelId != null) return;
|
||||
if (note.user.host == null) 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 (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);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
|
@ -90,7 +98,6 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
|
|||
public readonly kind = BubbleTimelineChannel.kind;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private readonly utilityService: UtilityService,
|
||||
|
@ -100,7 +107,6 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
|
|||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): BubbleTimelineChannel {
|
||||
return new BubbleTimelineChannel(
|
||||
this.metaService,
|
||||
this.roleService,
|
||||
this.utilityService,
|
||||
this.noteEntityService,
|
||||
|
|
|
@ -48,20 +48,36 @@ class GlobalTimelineChannel extends Channel {
|
|||
|
||||
@bindThis
|
||||
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.withBots && note.user.isBot) return;
|
||||
|
||||
if (note.visibility !== 'public') 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.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);
|
||||
await this.hideNote(clonedNote);
|
||||
|
|
|
@ -50,37 +50,29 @@ class HomeTimelineChannel extends Channel {
|
|||
if (!isMe && !Object.hasOwn(this.following, note.userId)) 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.isNoteVisibleToMe(note)) return;
|
||||
|
||||
if (note.reply) {
|
||||
const reply = note.reply;
|
||||
if (this.following[note.userId]?.withReplies) {
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
|
||||
} else {
|
||||
// 自分のフォローしていないユーザーの 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 (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
|
||||
|
||||
// 純粋なリノート(引用リノートでないリノート)の場合
|
||||
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
|
||||
if (!this.withRenotes) return;
|
||||
if (note.renote.reply) {
|
||||
const reply = note.renote.reply;
|
||||
// 自分のフォローしていないユーザーの 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);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
|
|
|
@ -67,34 +67,26 @@ class HybridTimelineChannel extends Channel {
|
|||
(note.channelId != null && this.followingChannels.has(note.channelId))
|
||||
)) 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.isNoteVisibleToMe(note)) return;
|
||||
|
||||
if (note.reply) {
|
||||
const reply = note.reply;
|
||||
if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) {
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
|
||||
} else {
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||
if (!this.isNoteVisibleToMe(reply)) return;
|
||||
if (!this.following[note.userId]?.withReplies && !this.withReplies) {
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
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 (!this.withRenotes) return;
|
||||
if (note.renote.reply) {
|
||||
const reply = note.renote.reply;
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
|
||||
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
|
||||
if (!this.isNoteVisibleToMe(reply)) return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,28 +50,37 @@ class LocalTimelineChannel extends Channel {
|
|||
|
||||
@bindThis
|
||||
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.withBots && note.user.isBot) return;
|
||||
|
||||
if (note.user.host !== null) return;
|
||||
if (note.visibility !== 'public') 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.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);
|
||||
await this.hideNote(clonedNote);
|
||||
|
|
|
@ -32,10 +32,12 @@ class MainChannel extends Channel {
|
|||
switch (data.type) {
|
||||
case 'notification': {
|
||||
// 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.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, {
|
||||
detail: true,
|
||||
});
|
||||
|
@ -44,9 +46,7 @@ class MainChannel extends Channel {
|
|||
break;
|
||||
}
|
||||
case 'mention': {
|
||||
if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
|
||||
|
||||
if (this.userIdsWhoMeMuting.has(data.body.userId)) return;
|
||||
if (this.isNoteMutedOrBlocked(data.body)) return;
|
||||
if (data.body.isHidden) {
|
||||
const note = await this.noteEntityService.pack(data.body.id, this.user, {
|
||||
detail: true,
|
||||
|
|
|
@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
|
||||
class RoleTimelineChannel extends Channel {
|
||||
|
@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel {
|
|||
private async onEvent(data: GlobalEvents['roleTimeline']['payload']) {
|
||||
if (data.type === 'note') {
|
||||
const note = data.body;
|
||||
const isMe = this.user?.id === note.userId;
|
||||
|
||||
// TODO this should be cached
|
||||
if (!(await this.roleservice.isExplorable({ id: this.roleId }))) {
|
||||
return;
|
||||
}
|
||||
|
@ -48,6 +51,25 @@ class RoleTimelineChannel extends Channel {
|
|||
|
||||
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);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js';
|
|||
class UserListChannel extends Channel {
|
||||
public readonly chName = 'userList';
|
||||
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 membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
|
||||
private listUsersClock: NodeJS.Timeout;
|
||||
|
@ -81,7 +82,7 @@ class UserListChannel extends Channel {
|
|||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
const isMe = this.user!.id === note.userId;
|
||||
const isMe = this.user?.id === note.userId;
|
||||
|
||||
// チャンネル投稿は無視する
|
||||
if (note.channelId) return;
|
||||
|
@ -90,26 +91,28 @@ class UserListChannel extends Channel {
|
|||
|
||||
if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||
} else if (note.visibility === 'specified') {
|
||||
if (!note.visibleUserIds!.includes(this.user!.id)) return;
|
||||
}
|
||||
if (this.isNoteMutedOrBlocked(note)) return;
|
||||
if (!this.isNoteVisibleToMe(note)) return;
|
||||
|
||||
if (note.reply) {
|
||||
const reply = note.reply;
|
||||
if (this.membershipsMap[note.userId]?.withReplies) {
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
|
||||
} else {
|
||||
// 自分のフォローしていないユーザーの 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) && !this.withRenotes) return;
|
||||
|
||||
if (this.isNoteMutedOrBlocked(note)) 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);
|
||||
await this.hideNote(clonedNote);
|
||||
|
@ -128,7 +131,7 @@ class UserListChannel extends Channel {
|
|||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserListChannelService implements MiChannelService<false> {
|
||||
export class UserListChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = UserListChannel.shouldShare;
|
||||
public readonly requireCredential = UserListChannel.requireCredential;
|
||||
public readonly kind = UserListChannel.kind;
|
||||
|
|
|
@ -20,6 +20,7 @@ import { RedisKVCache } from '@/misc/cache.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||
import type { MiAccessToken, NotesRepository } from '@/models/_.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { ApRequestService } from '@/core/activitypub/ApRequestService.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 { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export type LocalSummalyResult = SummalyResult & {
|
||||
haveNoteLocally?: boolean;
|
||||
linkAttribution?: {
|
||||
userId: string,
|
||||
}
|
||||
};
|
||||
|
||||
// Increment this to invalidate cached previews after a major change.
|
||||
|
@ -82,6 +87,7 @@ export class UrlPreviewService {
|
|||
private readonly utilityService: UtilityService,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly apDbResolverService: ApDbResolverService,
|
||||
private readonly remoteUserResolveService: RemoteUserResolveService,
|
||||
private readonly apRequestService: ApRequestService,
|
||||
private readonly systemAccountService: SystemAccountService,
|
||||
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.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
|
||||
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||
const [user, app] = auth;
|
||||
|
|
31
packages/frontend-shared/js/retry-on-throttled.ts
Normal file
31
packages/frontend-shared/js/retry-on-throttled.ts
Normal 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;
|
||||
}
|
|
@ -65,6 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</footer>
|
||||
</article>
|
||||
</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">
|
||||
<div v-if="tweetId" :class="$style.action">
|
||||
<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 DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
|
||||
import { $i } from '@/i';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
|
@ -146,6 +158,10 @@ const player = ref<SummalyResult['player']>({
|
|||
height: null,
|
||||
allow: [],
|
||||
});
|
||||
const linkAttribution = ref<{
|
||||
userId: string,
|
||||
} | null>(null);
|
||||
const attributionUser = ref<Misskey.entities.User | null>(null);
|
||||
const playerEnabled = ref(false);
|
||||
const tweetId = ref<string | null>(null);
|
||||
const tweetExpanded = ref(props.detail);
|
||||
|
@ -221,7 +237,12 @@ function refresh(withFetch = false) {
|
|||
|
||||
return res.json();
|
||||
})
|
||||
.then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => {
|
||||
.then(async (info: SummalyResult & {
|
||||
haveNoteLocally?: boolean,
|
||||
linkAttribution?: {
|
||||
userId: string,
|
||||
}
|
||||
} | null) => {
|
||||
unknownUrl.value = info == null;
|
||||
title.value = info?.title ?? null;
|
||||
description.value = info?.description ?? null;
|
||||
|
@ -236,6 +257,16 @@ function refresh(withFetch = false) {
|
|||
};
|
||||
sensitive.value = info?.sensitive ?? false;
|
||||
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;
|
||||
if (info?.haveNoteLocally) {
|
||||
|
@ -395,6 +426,28 @@ refresh();
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
|
|
@ -11,8 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { retryOnThrottled } from '@@/js/retry-on-throttled.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
@ -20,16 +21,25 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
const props = defineProps<{
|
||||
block: Misskey.entities.PageBlock,
|
||||
page: Misskey.entities.Page,
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
const note = ref<Misskey.entities.Note | null>(null);
|
||||
|
||||
// eslint-disable-next-line id-denylist
|
||||
let timeoutId: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (props.block.note == null) return;
|
||||
misskeyApi('notes/show', { noteId: props.block.note })
|
||||
.then(result => {
|
||||
note.value = result;
|
||||
});
|
||||
timeoutId = window.setTimeout(async () => {
|
||||
note.value = await retryOnThrottled(() => misskeyApi('notes/show', { noteId: props.block.note }));
|
||||
}, 500 * props.index); // rate limit is 2 reqs per sec
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
/* eslint-disable vue/no-mutating-props */
|
||||
import { watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { retryOnThrottled } from '@@/js/retry-on-throttled.js';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
|
@ -35,6 +36,7 @@ import { i18n } from '@/i18n.js';
|
|||
|
||||
const props = defineProps<{
|
||||
modelValue: Misskey.entities.PageBlock & { type: 'note' };
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -58,7 +60,13 @@ watch(id, async () => {
|
|||
...props.modelValue,
|
||||
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,
|
||||
});
|
||||
|
|
|
@ -5,10 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<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)">
|
||||
<template #item="{element}">
|
||||
<template #item="{element, index}">
|
||||
<div :class="$style.item">
|
||||
<!-- 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>
|
||||
</template>
|
||||
</Sortable>
|
||||
|
|
|
@ -15,36 +15,50 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInfo from '@/components/MkInfo.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 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);
|
||||
|
||||
async function save() {
|
||||
let mutes = instanceMutes.value
|
||||
.trim().split('\n')
|
||||
.map(el => el.trim())
|
||||
.filter(el => el);
|
||||
// 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', {
|
||||
mutedInstances: mutes,
|
||||
mutedInstances: domainArray.value,
|
||||
});
|
||||
|
||||
changed.value = false;
|
||||
|
||||
// Refresh filtered list to signal to the user how they've been saved
|
||||
instanceMutes.value = mutes.join('\n');
|
||||
instanceMutes.value = domainArray.value.join('\n');
|
||||
|
||||
changed.value = false;
|
||||
}
|
||||
|
||||
watch(instanceMutes, () => {
|
||||
changed.value = true;
|
||||
watch(domainArray, (newArray, oldArray) => {
|
||||
// compare arrays
|
||||
if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) {
|
||||
changed.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -163,6 +163,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #caption>{{ i18n.ts.flagAsBotDescription }}</template>
|
||||
</MkSwitch>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker
|
||||
:label="i18n.ts.attributionDomains"
|
||||
:keywords="['attribution', 'domains', 'preview', 'url']"
|
||||
>
|
||||
<AttributionDomainsSettings/>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
@ -172,6 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue';
|
||||
import AttributionDomainsSettings from './profile.attribution-domains-setting.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
|
|
|
@ -598,6 +598,10 @@ roleAutomatic: "automatic"
|
|||
translationTimeoutLabel: "Translation timeout"
|
||||
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)"
|
||||
followersSub: "Followers (Sub)"
|
||||
wellKnownResources: "Well-known resources"
|
||||
|
|
|
@ -7,3 +7,6 @@ openRemoteProfile: "Abrir perfil remoto"
|
|||
allowClickingNotifications: "Permitir clicar em notificações"
|
||||
pinnedOnly: "Fixado"
|
||||
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}"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue