add: isSilenced handling to user and timeline

This commit is contained in:
Mar0xy 2023-10-18 13:34:16 +02:00
parent 95b2689a21
commit a4a1b8bb8b
No known key found for this signature in database
GPG key ID: 56569BBE47D2C828
19 changed files with 171 additions and 11 deletions

View file

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

View file

@ -368,6 +368,7 @@ export class UserEntityService implements OnModuleInit {
createdAt: this.idService.parse(user.id).date.toISOString(), createdAt: this.idService.parse(user.id).date.toISOString(),
isBot: user.isBot ?? falsy, isBot: user.isBot ?? falsy,
isCat: user.isCat ?? falsy, isCat: user.isCat ?? falsy,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? falsy, speakAsCat: user.speakAsCat ?? falsy,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name, name: instance.name,
@ -404,7 +405,6 @@ export class UserEntityService implements OnModuleInit {
backgroundUrl: user.backgroundUrl, backgroundUrl: user.backgroundUrl,
backgroundBlurhash: user.backgroundBlurhash, backgroundBlurhash: user.backgroundBlurhash,
isLocked: user.isLocked, isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended ?? falsy, isSuspended: user.isSuspended ?? falsy,
location: profile!.location, location: profile!.location,
birthday: profile!.birthday, birthday: profile!.birthday,

View file

@ -173,6 +173,12 @@ export class MiUser {
}) })
public isSuspended: boolean; public isSuspended: boolean;
@Column('boolean', {
default: false,
comment: 'Whether the User is silenced.',
})
public isSilenced: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
comment: 'Whether the User is locked.', comment: 'Whether the User is locked.',

View file

@ -47,6 +47,10 @@ export const packedUserLiteSchema = {
nullable: false, optional: true, nullable: false, optional: true,
default: false, default: false,
}, },
isSilenced: {
type: 'boolean',
nullable: false, optional: false,
},
isBot: { isBot: {
type: 'boolean', type: 'boolean',
nullable: false, optional: true, nullable: false, optional: true,
@ -135,10 +139,6 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: false, nullable: false, optional: false,
}, },
isSilenced: {
type: 'boolean',
nullable: false, optional: false,
},
isSuspended: { isSuspended: {
type: 'boolean', type: 'boolean',
nullable: false, optional: false, nullable: false, optional: false,

View file

@ -63,6 +63,8 @@ import * as ep___admin_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js';
import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js';
import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js';
import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js';
import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
@ -418,6 +420,8 @@ const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep_
const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default };
const $admin_nsfwUser: Provider = { provide: 'ep:admin/nsfw-user', useClass: ep___admin_nsfwUser.default }; const $admin_nsfwUser: Provider = { provide: 'ep:admin/nsfw-user', useClass: ep___admin_nsfwUser.default };
const $admin_unnsfwUser: Provider = { provide: 'ep:admin/unnsfw-user', useClass: ep___admin_unnsfwUser.default }; const $admin_unnsfwUser: Provider = { provide: 'ep:admin/unnsfw-user', useClass: ep___admin_unnsfwUser.default };
const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default };
const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.default };
const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default };
const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default };
const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default };
@ -777,6 +781,8 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$admin_showUsers, $admin_showUsers,
$admin_nsfwUser, $admin_nsfwUser,
$admin_unnsfwUser, $admin_unnsfwUser,
$admin_silenceUser,
$admin_unsilenceUser,
$admin_suspendUser, $admin_suspendUser,
$admin_unsuspendUser, $admin_unsuspendUser,
$admin_updateMeta, $admin_updateMeta,
@ -1130,6 +1136,8 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$admin_showUsers, $admin_showUsers,
$admin_nsfwUser, $admin_nsfwUser,
$admin_unnsfwUser, $admin_unnsfwUser,
$admin_silenceUser,
$admin_unsilenceUser,
$admin_suspendUser, $admin_suspendUser,
$admin_unsuspendUser, $admin_unsuspendUser,
$admin_updateMeta, $admin_updateMeta,

View file

@ -63,6 +63,8 @@ import * as ep___admin_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js';
import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js';
import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js';
import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js';
import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
@ -416,6 +418,8 @@ const eps = [
['admin/show-users', ep___admin_showUsers], ['admin/show-users', ep___admin_showUsers],
['admin/nsfw-user', ep___admin_nsfwUser], ['admin/nsfw-user', ep___admin_nsfwUser],
['admin/unnsfw-user', ep___admin_unnsfwUser], ['admin/unnsfw-user', ep___admin_unnsfwUser],
['admin/silence-user', ep___admin_silenceUser],
['admin/unsilence-user', ep___admin_unsilenceUser],
['admin/suspend-user', ep___admin_suspendUser], ['admin/suspend-user', ep___admin_suspendUser],
['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/unsuspend-user', ep___admin_unsuspendUser],
['admin/update-meta', ep___admin_updateMeta], ['admin/update-meta', ep___admin_updateMeta],

View file

@ -58,7 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
const isModerator = await this.roleService.isModerator(user); const isModerator = await this.roleService.isModerator(user);
const isSilenced = !(await this.roleService.getUserPolicies(user.id)).canPublicNote; const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote;
const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); const _me = await this.usersRepository.findOneByOrFail({ id: me.id });
if (!await this.roleService.isAdministrator(_me) && await this.roleService.isAdministrator(user)) { if (!await this.roleService.isAdministrator(_me) && await this.roleService.isAdministrator(user)) {

View file

@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user == null) {
throw new Error('user not found');
}
if (await this.roleService.isModerator(user)) {
throw new Error('cannot silence moderator account');
}
await this.usersRepository.update(user.id, {
isSilenced: true,
});
});
}
}

View file

@ -0,0 +1,39 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user == null) {
throw new Error('user not found');
}
await this.usersRepository.update(user.id, {
isSilenced: false,
});
});
}
}

View file

@ -13,6 +13,7 @@ import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -61,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -68,6 +70,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled); throw new ApiError(meta.errors.gtlDisabled);
} }
const [
followings,
] = me ? await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
]) : [undefined];
//#region Construct query //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
@ -92,7 +100,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
//#endregion //#endregion
const timeline = await query.limit(ps.limit).getMany(); let 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(() => { process.nextTick(() => {
if (me) { if (me) {

View file

@ -87,10 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
const [ const [
followings,
userIdsWhoMeMuting, userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes, userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe, userIdsWhoBlockingMe,
] = await Promise.all([ ] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id), this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
@ -151,6 +153,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withRenotes === false) return false; if (ps.withRenotes === false) return false;
} }
} }
if (note.user?.isSilenced && note.userId !== me.id && !followings[note.userId]) return false;
return true; return true;
}); });

View file

@ -83,14 +83,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
const [ const [
followings,
userIdsWhoMeMuting, userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes, userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe, userIdsWhoBlockingMe,
] = me ? await Promise.all([ ] = me ? await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id), this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()]; ]) : [undefined, new Set<string>(), new Set<string>(), new Set<string>()];
let noteIds: string[]; let noteIds: string[];
@ -137,6 +139,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withRenotes === false) return false; if (ps.withRenotes === false) return false;
} }
} }
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
return true; return true;
}); });

View file

@ -117,6 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (note.reply && note.reply.visibility === 'followers') { if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false; if (!Object.hasOwn(followings, note.reply.userId)) return false;
} }
if (note.user?.isSilenced && note.userId !== me.id && !followings[note.userId]) return false;
return true; return true;
}); });

View file

@ -62,6 +62,8 @@ class GlobalTimelineChannel extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; 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 (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// Ignore notes from instances the user has muted // Ignore notes from instances the user has muted

View file

@ -64,6 +64,8 @@ class HomeTimelineChannel extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; 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 (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -81,6 +81,8 @@ class HybridTimelineChannel extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; 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 (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -64,6 +64,8 @@ class LocalTimelineChannel extends Channel {
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; 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 (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -76,6 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection> <FormSection>
<div class="_gaps"> <div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
<MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch> <MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
@ -306,6 +307,19 @@ async function toggleNSFW(v) {
} }
} }
async function toggleSilence(v) {
const confirm = await os.confirm({
type: 'warning',
text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
});
if (confirm.canceled) {
silenced = !v;
} else {
await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id });
await refreshUser();
}
}
async function toggleSuspend(v) { async function toggleSuspend(v) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'warning', type: 'warning',

View file

@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="narrow ? 800 : 1100" :style="background"> <MkSpacer :contentMax="narrow ? 800 : 1100" :style="background">
<div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;">
<div class="main _gaps"> <div class="main _gaps">
<!-- TODO --> <MkInfo v-if="user.isSuspended" :warn="true">{{ i18n.ts.userSuspended }}</MkInfo>
<!-- <div class="punished" v-if="user.isSuspended"><i class="ph-warning ph-bold ph-lg" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> <MkInfo v-if="user.isSilenced" :warn="true">{{ i18n.ts.userSilenced }}</MkInfo>
<!-- <div class="punished" v-if="user.isSilenced"><i class="ph-warning ph-bold ph-lg" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps"> <div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>