merge: Allow restricting announcement to a set of roles (!1161)

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

Closes #682

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Marie 2025-08-16 08:11:08 +00:00
commit d872c6608f
12 changed files with 96 additions and 1 deletions

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: наб and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AnnouncementForRoles1752352800438 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" ADD "forRoles" text[] DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forRoles"`);
}
}

View file

@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
@Injectable()
export class AnnouncementService {
@ -31,6 +32,7 @@ export class AnnouncementService {
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private announcementEntityService: AnnouncementEntityService,
private roleService: RoleService,
) {
}
@ -46,6 +48,7 @@ export class AnnouncementService {
const readsQuery = this.announcementReadsRepository.createQueryBuilder('read')
.select('read.announcementId')
.where('read.userId = :userId', { userId: user.id });
const roles = await this.roleService.getUserRoles(user);
const q = this.announcementsRepository.createQueryBuilder('announcement')
.where('announcement.isActive = true')
@ -58,6 +61,10 @@ export class AnnouncementService {
qb.orWhere('announcement.forExistingUsers = false');
qb.orWhere('announcement.id > :userId', { userId: user.id });
}))
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
qb.orWhere('announcement.forRoles = \'{}\'');
}))
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`);
q.setParameters(readsQuery.getParameters());
@ -76,6 +83,7 @@ export class AnnouncementService {
icon: values.icon,
display: values.display,
forExistingUsers: values.forExistingUsers,
forRoles: values.forRoles,
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
confetti: values.confetti,
@ -129,6 +137,7 @@ export class AnnouncementService {
display: values.display,
icon: values.icon,
forExistingUsers: values.forExistingUsers,
forRoles: values.forRoles,
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
confetti: values.confetti,

View file

@ -6,6 +6,7 @@
import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiRole } from './Role.js';
@Entity('announcement')
export class MiAnnouncement {
@ -66,6 +67,12 @@ export class MiAnnouncement {
})
public forExistingUsers: boolean;
@Column('text', {
array: true,
default: '{}', nullable: false,
})
public forRoles: MiRole['id'][];
@Index()
@Column('boolean', {
default: false,

View file

@ -59,6 +59,7 @@ export const paramDef = {
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' },
display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' },
forExistingUsers: { type: 'boolean', default: false },
forRoles: { type: 'array', default: [], items: { type: 'string', nullable: false, format: 'misskey:id' }, },
silence: { type: 'boolean', default: false },
needConfirmationToRead: { type: 'boolean', default: false },
confetti: { type: 'boolean', default: false },
@ -82,6 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
icon: ps.icon,
display: ps.display,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,

View file

@ -57,6 +57,15 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
forRoles: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'misskey:id'
}
},
},
},
},
@ -122,6 +131,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
display: announcement.display,
isActive: announcement.isActive,
forExistingUsers: announcement.forExistingUsers,
forRoles: announcement.forRoles,
silence: announcement.silence,
needConfirmationToRead: announcement.needConfirmationToRead,
confetti: announcement.confetti,

View file

@ -36,6 +36,7 @@ export const paramDef = {
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] },
display: { type: 'string', enum: ['normal', 'banner', 'dialog'] },
forExistingUsers: { type: 'boolean' },
forRoles: { type: 'array', default: [], items: { type: 'string', nullable: false, format: 'misskey:id' }, },
silence: { type: 'boolean' },
needConfirmationToRead: { type: 'boolean' },
confetti: { type: 'boolean' },
@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
display: ps.display,
icon: ps.icon,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { DI } from '@/di-symbols.js';
import type { AnnouncementsRepository } from '@/models/_.js';
@ -51,14 +52,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementsRepository: AnnouncementsRepository,
private queryService: QueryService,
private roleService: RoleService,
private announcementEntityService: AnnouncementEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const roles = me ? await this.roleService.getUserRoles(me) : [];
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
.andWhere('announcement.isActive = :isActive', { isActive: ps.isActive })
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id });
qb.orWhere('announcement.userId IS NULL');
}))
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
qb.orWhere('announcement.forRoles = \'{}\'');
}));
const announcements = await query.limit(ps.limit).getMany();

View file

@ -27,6 +27,7 @@ import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@ -41,6 +42,7 @@ describe('AnnouncementService', () => {
let announcementReadsRepository: AnnouncementReadsRepository;
let globalEventService: jest.Mocked<GlobalEventService>;
let moderationLogService: jest.Mocked<ModerationLogService>;
let roleService: jest.Mocked<RoleService>;
function createUser(data: Partial<MiUser> = {}) {
const un = secureRndstr(16);
@ -77,6 +79,7 @@ describe('AnnouncementService', () => {
InternalEventService,
GlobalEventService,
ModerationLogService,
RoleService,
],
})
.useMocker((token) => {
@ -93,6 +96,9 @@ describe('AnnouncementService', () => {
.overrideProvider(ModerationLogService).useValue({
log: jest.fn(),
})
.overrideProvider(RoleService).useValue({
getUserRoles: jest.fn((_) => []),
})
.overrideProvider(InternalEventService).useClass(FakeInternalEventService)
.overrideProvider(CacheService).useClass(NoOpCacheService)
.compile();
@ -105,6 +111,7 @@ describe('AnnouncementService', () => {
announcementReadsRepository = app.get<AnnouncementReadsRepository>(DI.announcementReadsRepository);
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
roleService = app.get<RoleService>(RoleService) as jest.Mocked<RoleService>;
});
afterEach(async () => {

View file

@ -100,7 +100,7 @@ async function fetchRoles() {
async function addRole() {
const items = roles.value
.filter(r => r.isPublic)
.filter(r => publicOnly.value ? r.isPublic : true)
.filter(r => !selectedRoleIds.value.includes(r.id))
.map(r => ({ text: r.name, value: r }));

View file

@ -75,6 +75,13 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._announcement.confetti }}
</MkSwitch>
</MkDisableSection>
<div :class="$style.forRoles">
<MkInfo v-if="announcement.forRoles.length !== 0" :class="$style.forRolesLabel">{{ i18n.tsx._announcement.onlyForRolesRestricted({roles: announcement.forRoles.length}) }}</MkInfo>
<MkInfo v-else :class="$style.forRolesLabel">{{ i18n.ts._announcement.onlyForRolesUnrestricted }}</MkInfo>
<MkButton primary @click="() => changeRoles(announcement)">
{{ i18n.ts._announcement.onlyForRolesChange }}
</MkButton>
</div>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
</div>
</MkFolder>
@ -134,9 +141,21 @@ function add() {
silence: false,
needConfirmationToRead: false,
confetti: false,
forRoles: [],
});
}
async function changeRoles(announcement) {
const result = await os.selectRole({
initialRoleIds: announcement.forRoles,
title: i18n.ts._announcement.onlyForRoles,
publicOnly: false,
});
if (result.canceled) return;
announcement.forRoles = result.result.map((r) => r.id);
}
function del(announcement) {
os.confirm({
type: 'warning',
@ -209,3 +228,12 @@ definePage(() => ({
icon: 'ti ti-speakerphone',
}));
</script>
<style lang="scss" module>
.forRoles {
display: flex;
}
.forRolesLabel {
flex-grow: 1;
}
</style>

View file

@ -6694,6 +6694,8 @@ export type operations = {
display?: 'normal' | 'banner' | 'dialog';
/** @default false */
forExistingUsers?: boolean;
/** @default [] */
forRoles?: string[];
/** @default false */
silence?: boolean;
/** @default false */
@ -6856,6 +6858,7 @@ export type operations = {
title: string;
imageUrl: string | null;
reads: number;
forRoles: string[];
})[];
};
};
@ -6911,6 +6914,8 @@ export type operations = {
/** @enum {string} */
display?: 'normal' | 'banner' | 'dialog';
forExistingUsers?: boolean;
/** @default [] */
forRoles?: string[];
silence?: boolean;
needConfirmationToRead?: boolean;
confetti?: boolean;

View file

@ -479,6 +479,10 @@ _auth:
allowed: "Allowed"
_announcement:
new: "New"
onlyForRoles: "Restrict to roles"
onlyForRolesChange: "Change role restrictions"
onlyForRolesUnrestricted: "Shown to everyone"
onlyForRolesRestricted: "Shown to members of {roles} roles"
confetti: "Throw confetti"
confettiDescription: "If enabled, the announcement will display a confetti effect when viewed."
_deck: