merge: Expand Mandatory CW feature and fixup block/mute/silence features (resolves #809, #910, #912, #943, #1064, #1142, and #1186) (!1148)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1148

Closes #809, #910, #912, #943, #1064, #1142, and #1186

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Marie 2025-09-25 20:05:46 +02:00
commit 741e612508
125 changed files with 3195 additions and 1338 deletions

View file

@ -34,6 +34,8 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor
export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js';
export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js';
export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js';
export * as 'admin/cw-instance' from './endpoints/admin/cw-instance.js';
export * as 'admin/cw-note' from './endpoints/admin/cw-note.js';
export * as 'admin/cw-user' from './endpoints/admin/cw-user.js';
export * as 'admin/decline-user' from './endpoints/admin/decline-user.js';
export * as 'admin/delete-account' from './endpoints/admin/delete-account.js';

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-instance',
res: {},
} as const;
export const paramDef = {
type: 'object',
properties: {
host: { type: 'string' },
cw: { type: 'string', nullable: true },
},
required: ['host', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private readonly moderationLogService: ModerationLogService,
private readonly federatedInstanceService: FederatedInstanceService,
) {
super(meta, paramDef, async (ps, me) => {
const instance = await this.federatedInstanceService.fetchOrRegister(ps.host);
// Collapse empty strings to null
const newCW = ps.cw?.trim() || null;
const oldCW = instance.mandatoryCW;
// Skip if there's nothing to do
if (oldCW === newCW) return;
// This synchronizes caches automatically
await this.federatedInstanceService.update(instance.id, { mandatoryCW: newCW });
await this.moderationLogService.log(me, 'setMandatoryCWForInstance', {
newCW,
oldCW,
host: ps.host,
});
});
}
}

View file

@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, DriveFilesRepository, MiNote, NotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { NoteEditService, Option } from '@/core/NoteEditService.js';
import { CacheService } from '@/core/CacheService.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-note',
res: {},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['noteId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
@Inject(DI.driveFilesRepository)
private readonly driveFilesRepository: DriveFilesRepository,
@Inject(DI.channelsRepository)
private readonly channelsRepository: ChannelsRepository,
private readonly noteEditService: NoteEditService,
private readonly moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.notesRepository.findOneOrFail({
where: { id: ps.noteId },
relations: { reply: true, renote: true, channel: true },
});
const user = await this.cacheService.findUserById(note.userId);
// Collapse empty strings to null
const newCW = ps.cw?.trim() || null;
const oldCW = note.mandatoryCW;
// Skip if there's nothing to do
if (oldCW === newCW) return;
// TODO remove this after merging hazelnoot/fix-note-edit-logic.
// Until then, we have to ensure that everything is populated just like it would be from notes/edit.ts.
// Otherwise forcing a CW will erase everything else in the note.
// After merging remove all the "createUpdate" stuff and just pass "{ mandatoryCW: newCW }" into noteEditService.edit().
const update = await this.createUpdate(note, newCW);
await this.noteEditService.edit(user, note.id, update);
await this.moderationLogService.log(me, 'setMandatoryCWForNote', {
newCW,
oldCW,
noteId: note.id,
noteUserId: user.id,
noteUserUsername: user.username,
noteUserHost: user.host,
});
});
}
// Note: user must be fetched with reply, renote, and channel relations populated
private async createUpdate(note: MiNote, newCW: string | null) {
// This is based on the call to NoteEditService.edit from notes/edit endpoint.
// noinspection ES6MissingAwait
return await awaitAll<Option>({
// Preserve these from original note
files: note.fileIds.length > 0
? this.driveFilesRepository.findBy({ id: In(note.fileIds) }) : null,
poll: undefined,
text: undefined,
cw: undefined,
reply: note.reply
?? (note.replyId ? this.notesRepository.findOneByOrFail({ id: note.replyId }) : null),
renote: note.renote
?? (note.renoteId ? this.notesRepository.findOneByOrFail({ id: note.renoteId }) : null),
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
visibility: note.visibility,
visibleUsers: note.visibleUserIds.length > 0
? this.cacheService.getUsers(note.visibleUserIds).then(us => Array.from(us.values())) : null,
channel: note.channel ?? (note.channelId ? this.channelsRepository.findOneByOrFail({ id: note.channelId }) : null),
apMentions: undefined,
apHashtags: undefined,
apEmojis: undefined,
// But override the mandatory CW!
mandatoryCW: newCW,
});
}
}

View file

@ -17,6 +17,8 @@ export const meta = {
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-user',
res: {},
} as const;
export const paramDef = {
@ -41,21 +43,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const user = await this.cacheService.findUserById(ps.userId);
// Skip if there's nothing to do
if (user.mandatoryCW === ps.cw) return;
// Collapse empty strings to null
const newCW = ps.cw?.trim() || null;
const oldCW = user.mandatoryCW;
await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});
// Skip if there's nothing to do
if (oldCW === newCW) return;
await this.usersRepository.update(ps.userId, { mandatoryCW: newCW });
// Synchronize caches and other processes
this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
const evt = user.host == null ? 'localUserUpdated' : 'remoteUserUpdated';
this.globalEventService.publishInternalEvent(evt, { id: ps.userId });
await this.moderationLogService.log(me, 'setMandatoryCW', {
newCW: ps.cw,
oldCW: user.mandatoryCW,
newCW,
oldCW,
userId: user.id,
userUsername: user.username,
userHost: user.host,

View file

@ -24,7 +24,6 @@ export const paramDef = {
properties: {
host: { type: 'string' },
isSuspended: { type: 'boolean' },
isNSFW: { type: 'boolean' },
rejectReports: { type: 'boolean' },
moderationNote: { type: 'string' },
rejectQuotes: { type: 'boolean' },
@ -58,7 +57,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.federatedInstanceService.update(instance.id, {
suspensionState,
isNSFW: ps.isNSFW,
rejectReports: ps.rejectReports,
rejectQuotes: ps.rejectQuotes,
moderationNote: ps.moderationNote,
@ -78,14 +76,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
if (ps.isNSFW != null && instance.isNSFW !== ps.isNSFW) {
const message = ps.rejectReports ? 'setRemoteInstanceNSFW' : 'unsetRemoteInstanceNSFW';
this.moderationLogService.log(me, message, {
id: instance.id,
host: instance.host,
});
}
if (ps.rejectReports != null && instance.rejectReports !== ps.rejectReports) {
const message = ps.rejectReports ? 'rejectRemoteInstanceReports' : 'acceptRemoteInstanceReports';
this.moderationLogService.log(me, message, {

View file

@ -122,6 +122,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View file

@ -107,8 +107,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
}
const threadMutings = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null;
return await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -122,13 +120,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
dbFallback: async (untilId, sinceId, limit) => {
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me);
},
noteFilter: note => {
if (threadMutings?.has(note.threadId ?? note.id)) {
return false;
}
return true;
},
});
});
}

View file

@ -71,10 +71,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const isModerator = await this.roleService.isModerator(me);
// Fetch file
const file = await this.driveFilesRepository.findOneBy({
id: ps.fileId,
userId: await this.roleService.isModerator(me) ? undefined : me.id,
userId: isModerator ? undefined : me.id,
});
if (file == null) {
@ -90,16 +92,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
if (!isModerator) {
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me, {
detail: true,
skipHide: isModerator,
});
});
}

View file

@ -9,6 +9,8 @@ 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 { ApiError } from '@/server/api/error.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
tags: ['notes'],
@ -23,6 +25,19 @@ export const meta = {
},
},
errors: {
gtlDisabled: {
message: 'Global timeline has been disabled.',
code: 'GTL_DISABLED',
id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b',
},
ltlDisabled: {
message: 'Local timeline has been disabled.',
code: 'LTL_DISABLED',
id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
},
},
// 120 calls per minute
// 200 ms between calls
limit: {
@ -55,8 +70,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
if (!ps.local && !policies.gtlAvailable) {
throw new ApiError(meta.errors.gtlDisabled);
}
if (ps.local && !policies.ltlAvailable) {
throw new ApiError(meta.errors.ltlDisabled);
}
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.visibility = \'public\'')
.andWhere('note.localOnly = FALSE')
@ -68,7 +92,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
@ -77,10 +100,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.local) {
query.andWhere('note.userHost IS NULL');
} else {
this.queryService.generateBlockedHostQueryForNote(query);
}
if (ps.reply !== undefined) {
query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL');
if (ps.reply) {
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
} else if (ps.reply === false) {
query.andWhere('note.replyId IS NULL');
}
if (ps.renote !== undefined) {

View file

@ -93,9 +93,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.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"');
.leftJoin(query, 'note.userInstance', 'userInstance');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);

View file

@ -26,10 +26,11 @@ export const meta = {
},
},
// 10 calls per 5 seconds
// Up to 25 calls, then 4 / second
limit: {
duration: 1000 * 5,
max: 10,
type: 'bucket',
size: 25,
dripRate: 250,
},
} as const;

View file

@ -18,6 +18,7 @@ import { NoteCreateService } from '@/core/NoteCreateService.js';
import { DI } from '@/di-symbols.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -261,6 +262,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.text && ps.text.length > this.config.maxNoteLength) {
@ -303,7 +305,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (isRenote(renote) && !isQuote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
} else if (!await this.noteEntityService.isVisibleForMe(renote, me.id)) {
} else if (!(await this.noteVisibilityService.checkNoteVisibilityAsync(renote, me)).accessible) {
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
}
@ -351,7 +353,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
} else if (!(await this.noteVisibilityService.checkNoteVisibilityAsync(reply, me)).accessible) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -17,6 +17,7 @@ import { NoteEditService } from '@/core/NoteEditService.js';
import { DI } from '@/di-symbols.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -311,6 +312,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private noteEditService: NoteEditService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.text && ps.text.length > this.config.maxNoteLength) {
@ -408,7 +410,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
} else if (!(await this.noteVisibilityService.checkNoteVisibilityAsync(reply, me)).accessible) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -102,14 +102,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
@ -120,15 +112,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel')
.andWhere('user.isExplorable = TRUE');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return true;
});
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);

View file

@ -150,11 +150,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Hide blocked users / instances
query.andWhere('"user"."isSuspended" = false');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me, ps.list !== 'followers');
this.queryService.generateSuspendedUserQueryForNote(query);
// Respect blocks, mutes, and privacy
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, ps.list !== 'followers');
// Support pagination
this.queryService

View file

@ -87,7 +87,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);

View file

@ -145,14 +145,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
];
}
const [
followings,
mutedThreads,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.threadMutingsCache.fetch(me.id),
]);
const redisTimeline = await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -161,20 +153,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me,
redisTimelines: timelineConfig,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false;
}
if (mutedThreads.has(note.threadId ?? note.id)) {
return false;
}
return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
@ -204,14 +184,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withRenotes: boolean,
}, me: MiLocalUser) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
// 1. by a user I follow, 2. a public local post, 3. my own post
// by a user I follow OR a public local post OR my own post
.andWhere(new Brackets(qb => this.queryService
.orFollowingUser(qb, ':meId', 'note.userId')
.orWhere(new Brackets(qbb => qbb
.andWhere('note.visibility = \'public\'')
.andWhere('note.userHost IS NULL')))
.orWhere(':meId = note.userId')))
// 1. in a channel I follow, 2. not in a channel
// in a channel I follow OR not in a channel
.andWhere(new Brackets(qb => this.queryService
.orFollowingChannel(qb, ':meId', 'note.channelId')
.orWhere('note.channelId IS NULL')))
@ -224,11 +204,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit);
if (!ps.withReplies) {
query
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')));
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
}
this.queryService.generateVisibilityQuery(query, me);

View file

@ -117,8 +117,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.noteEntityService.packMany(timeline, me);
}
const mutedThreads = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null;
const timeline = await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -131,7 +129,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
: ps.withReplies ? ['localTimeline', 'localTimelineWithReplies']
: me ? ['localTimeline', `localTimelineWithReplyTo:${me.id}`]
: ['localTimeline'],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
@ -143,13 +140,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me),
noteFilter: note => {
if (mutedThreads?.has(note.threadId ?? note.id)) {
return false;
}
return true;
},
});
if (me) {
@ -184,11 +174,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit);
if (!ps.withReplies) {
query
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')));
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
}
this.queryService.generateBlockedHostQueryForNote(query);

View file

@ -87,6 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(qb, me);
this.queryService.generateBlockedHostQueryForNote(qb);
this.queryService.generateSuspendedUserQueryForNote(qb);
this.queryService.generateSilencedUserQueryForNotes(qb, me);
this.queryService.generateMutedUserQueryForNotes(qb, me);
this.queryService.generateMutedNoteThreadQuery(qb, me);
this.queryService.generateBlockedUserQueryForNotes(qb, me);

View file

@ -137,10 +137,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
//#region block/mute/vis
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
//#endregion

View file

@ -93,9 +93,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
const renotes = await query.limit(ps.limit).getMany();

View file

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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);
if (me) this.queryService.generateMutedNoteThreadQuery(query, me);
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
@ -123,19 +123,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw e;
}
if (ps.reply != null) {
if (ps.reply === false) {
query.andWhere('note.replyId IS NULL');
} else {
if (ps.reply) {
query.andWhere('note.replyId IS NOT NULL');
} else {
query.andWhere('note.replyId IS NULL');
}
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
}
if (ps.renote != null) {
if (ps.renote === false) {
this.queryService.andIsNotRenote(query, 'note');
} else {
if (ps.renote) {
this.queryService.andIsRenote(query, 'note');
} else {
this.queryService.andIsNotRenote(query, 'note');
}
if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
}

View file

@ -97,14 +97,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.noteEntityService.packMany(timeline, me);
}
const [
followings,
threadMutings,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.threadMutingsCache.fetch(me.id),
]);
const timeline = this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -113,18 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false;
}
if (!ps.withBots && note.user?.isBot) return false;
if (threadMutings.has(note.threadId ?? note.id)) return false;
return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
@ -146,7 +127,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
// 1. in a channel I follow, 2. my own post, 3. by a user I follow
// in a channel I follow OR my own post OR by a user I follow
.andWhere(new Brackets(qb => this.queryService
.orFollowingChannel(qb, ':meId', 'note.channelId')
.orWhere(':meId = note.userId')
@ -154,10 +135,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andFollowingUser(qb2, ':meId', 'note.userId')
.andWhere('note.channelId IS NULL'))),
))
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
@ -166,6 +143,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);

View file

@ -15,6 +15,7 @@ import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { hasText } from '@/models/Note.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -84,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
private readonly cacheService: CacheService,
private readonly loggerService: ApiLoggerService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
@ -91,7 +93,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null, { me }))) {
const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(note, me);
if (!accessible) {
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}

View file

@ -125,8 +125,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
ignoreAuthorFromUserSilence: true,
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, {
untilId,
sinceId,
@ -156,16 +156,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => qb
// 返信ではない
.orWhere('note.replyId IS NULL')
// 返信だけど投稿者自身への返信
.orWhere('note.replyUserId = note.userId')
// 返信だけど自分宛ての返信
.orWhere('note.replyUserId = :meId')
// 返信だけどwithRepliesがtrueの場合
.orWhere('userListMemberships.withReplies = true'),
))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
@ -174,10 +164,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateExcludedRepliesQueryForNotes(query, me, 'userListMemberships.withReplies');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
this.queryService.generateMutedUserQueryForNotes(query, me, true);
this.queryService.generateBlockedUserQueryForNotes(query, me);
if (ps.withFiles) {

View file

@ -110,6 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);

View file

@ -78,12 +78,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
@ -95,13 +89,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
if (me) {
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, true);
}
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
return true;
});
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);

View file

@ -134,8 +134,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`);
if (ps.withChannelNotes) redisTimelines.push(`userTimelineWithChannel:${ps.userId}`);
const isFollowing = me && (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId);
const timeline = await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -147,14 +145,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
ignoreAuthorFromMute: true,
ignoreAuthorFromInstanceBlock: true,
ignoreAuthorFromUserSuspension: true,
ignoreAuthorFromUserSilence: true,
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
noteFilter: note => {
if (note.channel?.isSensitive && !isSelf) return false;
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
// These are handled by DB fallback, but we duplicate them here in case a timeline was already populated with notes
if (!ps.withRepliesToSelf && note.reply?.userId === note.userId) return false;
@ -218,12 +215,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('note.channelId IS NULL');
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query, true);
this.queryService.generateSuspendedUserQueryForNote(query, true);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
this.queryService.generateMutedUserQueryForNotes(query, me, true);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
if (ps.withFiles) {
@ -241,13 +239,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
query.andWhere('reply.id IS NULL');
} else if (!ps.withRepliesToOthers) {
query.andWhere('(reply.id IS NULL OR reply."userId" = note."userId")');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
} else if (!ps.withRepliesToSelf) {
query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")');
}
if (!ps.withNonPublic) {
query.andWhere('note.visibility = \'public\'');
} else {
this.queryService.generateVisibilityQuery(query, me);
}
if (!ps.withBots) {

View file

@ -100,8 +100,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set<string>();
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId })
@ -115,21 +113,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, true);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
const reactions = (await query
.limit(ps.limit)
.getMany()).filter(reaction => {
if (reaction.note?.userId === ps.userId) return true; // we can see reactions to note of requesting user
if (me && isUserRelated(reaction.note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(reaction.note, userIdsWhoMeMuting)) return false;
return true;
});
const reactions = await query.limit(ps.limit).getMany();
return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
});

View file

@ -23,6 +23,7 @@ import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.j
import { GetterService } from '@/server/api/GetterService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { isRenote } from '@/misc/is-renote.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
// Missing from Megalodon apparently
// https://docs.joinmastodon.org/entities/StatusEdit/
@ -68,6 +69,7 @@ export class MastodonConverters {
private readonly idService: IdService,
private readonly driveFileEntityService: DriveFileEntityService,
private readonly mastodonDataService: MastodonDataService,
private readonly federatedInstanceService: FederatedInstanceService,
) {}
private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention {
@ -210,6 +212,7 @@ export class MastodonConverters {
}
const noteUser = await this.getUser(note.userId);
const noteInstance = noteUser.instance ?? (noteUser.host ? await this.federatedInstanceService.fetch(noteUser.host) : null);
const account = await this.convertAccount(noteUser);
const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } });
const history: StatusEdit[] = [];
@ -224,7 +227,16 @@ export class MastodonConverters {
// TODO avoid re-packing files for each edit
const files = await this.driveFileEntityService.packManyByIds(edit.fileIds);
const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? '';
let cw = edit.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
if (noteInstance?.mandatoryCW) {
cw = appendContentWarning(cw, noteInstance.mandatoryCW);
}
const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId);
const quoteUri = isQuote
@ -299,7 +311,13 @@ export class MastodonConverters {
? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text))
: '';
const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? '';
let cw = note.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
const reblogged = await this.mastodonDataService.hasReblog(note.id, me);

View file

@ -12,6 +12,7 @@ import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { deepClone } from '@/misc/clone.js';
import type Connection from '@/server/api/stream/Connection.js';
import { NoteVisibilityFilters } from '@/core/NoteVisibilityService.js';
/**
* Stream channel
@ -26,6 +27,10 @@ export default abstract class Channel {
public static readonly requireCredential: boolean;
public static readonly kind?: string | null;
protected get noteVisibilityService() {
return this.noteEntityService.noteVisibilityService;
}
protected get user() {
return this.connection.user;
}
@ -105,8 +110,14 @@ export default abstract class Channel {
return this.connection.myRecentFavorites;
}
protected async checkNoteVisibility(note: Packed<'Note'>, filters?: NoteVisibilityFilters) {
// Don't use any of the local cached data, because this does everything through CacheService which is just as fast with updated data.
return await this.noteVisibilityService.checkNoteVisibilityAsync(note, this.user, { filters });
}
/**
* Checks if a note is visible to the current user *excluding* blocks and mutes.
* @deprecated use isNoteHidden instead
*/
protected isNoteVisibleToMe(note: Packed<'Note'>): boolean {
if (note.visibility === 'public') return true;
@ -120,8 +131,9 @@ export default abstract class Channel {
return note.visibleUserIds.includes(this.user.id);
}
/*
/**
*
* @deprecated use isNoteHidden instead
*/
protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
// Ignore notes that require sign-in
@ -196,12 +208,11 @@ export default abstract class Channel {
// If we didn't clone the notes here, different connections would asynchronously write
// different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar
const clonedNote = deepClone(note);
const notes = crawl(clonedNote);
// Hide notes before everything else, since this modifies fields that the other functions will check.
await this.noteEntityService.hideNotes(notes, this.user.id);
const notes = crawl(clonedNote);
const [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings] = await Promise.all([
const [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings, myFollowings] = await Promise.all([
this.noteEntityService.populateMyReactions(notes, this.user.id, {
myReactions: this.myRecentReactions,
}),
@ -213,13 +224,25 @@ export default abstract class Channel {
}),
this.noteEntityService.populateMyTheadMutings(notes, this.user.id),
this.noteEntityService.populateMyNoteMutings(notes, this.user.id),
this.cacheService.userFollowingsCache.fetch(this.user.id),
]);
note.myReaction = myReactions.get(note.id) ?? null;
note.isRenoted = myRenotes.has(note.id);
note.isFavorited = myFavorites.has(note.id);
note.isMutingThread = myThreadMutings.has(note.id);
note.isMutingNote = myNoteMutings.has(note.id);
for (const n of notes) {
// Sync visibility in case there's something like "makeNotesFollowersOnlyBefore" enabled
this.noteVisibilityService.syncVisibility(n);
n.myReaction = myReactions.get(n.id) ?? null;
n.isRenoted = myRenotes.has(n.id);
n.isFavorited = myFavorites.has(n.id);
n.isMutingThread = myThreadMutings.has(n.id);
n.isMutingNote = myNoteMutings.has(n.id);
n.user.bypassSilence = n.userId === this.user.id || myFollowings.has(n.userId);
}
// Hide notes *after* we sync visibility
await this.noteEntityService.hideNotes(notes, this.user.id, {
userFollowings: myFollowings,
});
return clonedNote;
}

View file

@ -41,7 +41,8 @@ class AntennaChannel extends Channel {
if (data.type === 'note') {
const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true });
if (this.isNoteMutedOrBlocked(note)) return;
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
this.send('note', note);
} else {

View file

@ -8,9 +8,9 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import Channel, { MiChannelService } from '../channel.js';
class BubbleTimelineChannel extends Channel {
@ -47,8 +47,6 @@ 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;
@ -56,27 +54,9 @@ class BubbleTimelineChannel extends Channel {
if (note.channelId != null) return;
if (!this.utilityService.isBubbledHost(note.user.host)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(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 { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -8,6 +8,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class ChannelChannel extends Channel {
@ -45,9 +46,9 @@ class ChannelChannel extends Channel {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
if (this.isNoteMutedOrBlocked(note)) return;
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ 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 { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -48,36 +48,15 @@ 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 (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.get(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 { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -43,7 +43,8 @@ class HashtagChannel extends Channel {
const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag))));
if (!matched) return;
if (this.isNoteMutedOrBlocked(note)) return;
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -7,7 +7,7 @@ import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -50,28 +50,9 @@ class HomeTimelineChannel extends Channel {
if (!isMe && !this.following.has(note.userId)) 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.get(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 { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ 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 { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -67,28 +67,10 @@ class HybridTimelineChannel extends Channel {
(note.channelId != null && this.followingChannels.has(note.channelId))
)) 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.get(note.userId)?.withReplies && !this.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 { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
if (!this.withReplies && note.replyId != null) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ 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 { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -50,8 +50,6 @@ 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;
@ -59,28 +57,10 @@ class LocalTimelineChannel extends Channel {
if (note.visibility !== 'public') return;
if (note.channelId != null) 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.get(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 { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
if (!this.withReplies && note.replyId != null) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -35,24 +35,19 @@ class MainChannel extends Channel {
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,
});
data.body.note = note;
if (data.body.note) {
const { accessible, silence } = await this.checkNoteVisibility(data.body.note, { includeReplies: true });
if (!accessible || silence) return;
data.body.note = await this.rePackNote(data.body.note);
}
break;
}
case 'mention': {
if (this.isNoteMutedOrBlocked(data.body)) return;
if (data.body.isHidden) {
const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true,
});
data.body = note;
}
const { accessible, silence } = await this.checkNoteVisibility(data.body, { includeReplies: true });
if (!accessible || silence) return;
data.body = await this.rePackNote(data.body);
break;
}
}

View file

@ -9,7 +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 { isPackedPureRenote, isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class RoleTimelineChannel extends Channel {
@ -49,26 +49,8 @@ class RoleTimelineChannel extends Channel {
}
if (note.visibility !== 'public') return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(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 { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -82,8 +82,6 @@ class UserListChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
// チャンネル投稿は無視する
if (note.channelId) return;
@ -91,28 +89,9 @@ class UserListChannel extends Channel {
if (!Object.hasOwn(this.membershipsMap, note.userId)) 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.get(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 { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);