Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

This commit is contained in:
syuilo 2025-04-07 19:45:31 +09:00
commit 7f1cd614db
45 changed files with 258 additions and 138 deletions

View File

@ -41,6 +41,7 @@
- 再度ログインすればサーバーのバックアップから設定データを復元可能です - 再度ログインすればサーバーのバックアップから設定データを復元可能です
- エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました
- 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です
- 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください
- Feat: 画面を重ねて表示するオプションを実装(実験的) - Feat: 画面を重ねて表示するオプションを実装(実験的)
- 設定 → その他 → 実験的機能 → Enable stacking router view - 設定 → その他 → 実験的機能 → Enable stacking router view
- Enhance: プラグインの管理が強化されました - Enhance: プラグインの管理が強化されました

10
locales/index.d.ts vendored
View File

@ -5386,6 +5386,10 @@ export interface Locale extends ILocale {
* ... ( ) * ... ( )
*/ */
"settingsMigrating": string; "settingsMigrating": string;
/**
*
*/
"readonly": string;
"_chat": { "_chat": {
/** /**
* *
@ -5500,6 +5504,10 @@ export interface Locale extends ILocale {
* *
*/ */
"chatNotAvailableForThisAccountOrServer": string; "chatNotAvailableForThisAccountOrServer": string;
/**
*
*/
"chatIsReadOnlyForThisAccountOrServer": string;
/** /**
* 使 * 使
*/ */
@ -7531,7 +7539,7 @@ export interface Locale extends ILocale {
/** /**
* *
*/ */
"canChat": string; "chatAvailability": string;
}; };
"_condition": { "_condition": {
/** /**

View File

@ -1342,6 +1342,7 @@ bottom: "下"
top: "上" top: "上"
embed: "埋め込み" embed: "埋め込み"
settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)"
readonly: "読み取り専用"
_chat: _chat:
noMessagesYet: "まだメッセージはありません" noMessagesYet: "まだメッセージはありません"
@ -1372,6 +1373,7 @@ _chat:
muteThisRoom: "このルームをミュート" muteThisRoom: "このルームをミュート"
deleteRoom: "ルームを削除" deleteRoom: "ルームを削除"
chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。"
chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。"
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できません" cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
@ -1950,7 +1952,7 @@ _role:
canImportFollowing: "フォローのインポートを許可" canImportFollowing: "フォローのインポートを許可"
canImportMuting: "ミュートのインポートを許可" canImportMuting: "ミュートのインポートを許可"
canImportUserLists: "リストのインポートを許可" canImportUserLists: "リストのインポートを許可"
canChat: "チャットを許可" chatAvailability: "チャットを許可"
_condition: _condition:
roleAssignedTo: "マニュアルロールにアサイン済み" roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.4.0-rc.1", "version": "2025.4.0-rc.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -94,6 +94,40 @@ export class ChatService {
) { ) {
} }
@bindThis
public async getChatAvailability(userId: MiUser['id']): Promise<{ read: boolean; write: boolean; }> {
const policies = await this.roleService.getUserPolicies(userId);
switch (policies.chatAvailability) {
case 'available':
return {
read: true,
write: true,
};
case 'readonly':
return {
read: true,
write: false,
};
case 'unavailable':
return {
read: false,
write: false,
};
default:
throw new Error('invalid chat availability (unreachable)');
}
}
/** getChatAvailabilityの糖衣。主にAPI呼び出し時に走らせて、権限的に問題ない場合はそのまま続行する */
@bindThis
public async checkChatAvailability(userId: MiUser['id'], permission: 'read' | 'write') {
const policy = await this.getChatAvailability(userId);
if (policy[permission] === false) {
throw new Error('ROLE_PERMISSION_DENIED');
}
}
@bindThis @bindThis
public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
text?: string | null; text?: string | null;
@ -140,7 +174,7 @@ export class ChatService {
} }
} }
if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) { if (!(await this.getChatAvailability(toUser.id)).write) {
throw new Error('recipient is cannot chat (policy)'); throw new Error('recipient is cannot chat (policy)');
} }

View File

@ -63,7 +63,7 @@ export type RolePolicies = {
canImportFollowing: boolean; canImportFollowing: boolean;
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
canChat: boolean; chatAvailability: 'available' | 'readonly' | 'unavailable';
}; };
export const DEFAULT_POLICIES: RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = {
@ -98,7 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportFollowing: true, canImportFollowing: true,
canImportMuting: true, canImportMuting: true,
canImportUserLists: true, canImportUserLists: true,
canChat: true, chatAvailability: 'available',
}; };
@Injectable() @Injectable()
@ -370,6 +370,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
} }
function aggregateChatAvailability(vs: RolePolicies['chatAvailability'][]) {
if (vs.some(v => v === 'available')) return 'available';
if (vs.some(v => v === 'readonly')) return 'readonly';
return 'unavailable';
}
return { return {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
@ -402,7 +408,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
canChat: calc('canChat', vs => vs.some(v => v === true)), chatAvailability: calc('chatAvailability', aggregateChatAvailability),
}; };
} }

View File

@ -557,7 +557,7 @@ export class UserEntityService implements OnModuleInit {
followersVisibility: profile!.followersVisibility, followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility, followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope, chatScope: user.chatScope,
canChat: this.roleService.getUserPolicies(user.id).then(r => r.canChat), canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id, id: role.id,
name: role.name, name: role.name,

View File

@ -292,9 +292,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canChat: { chatAvailability: {
type: 'boolean', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
enum: ['available', 'readonly', 'unavailable'],
}, },
}, },
} as const; } as const;

View File

@ -46,6 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me);

View File

@ -16,7 +16,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true, prohibitMoved: true,
@ -74,6 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
const room = await this.chatService.findRoomById(ps.toRoomId); const room = await this.chatService.findRoomById(ps.toRoomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -16,7 +16,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true, prohibitMoved: true,
@ -86,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
let file = null; let file = null;
if (ps.fileId != null) { if (ps.fileId != null) {
file = await this.driveFilesRepository.findOneBy({ file = await this.driveFilesRepository.findOneBy({

View File

@ -13,7 +13,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
kind: 'write:chat', kind: 'write:chat',
@ -43,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
const message = await this.chatService.findMyMessageById(me.id, ps.messageId); const message = await this.chatService.findMyMessageById(me.id, ps.messageId);
if (message == null) { if (message == null) {
throw new ApiError(meta.errors.noSuchMessage); throw new ApiError(meta.errors.noSuchMessage);

View File

@ -13,7 +13,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
kind: 'write:chat', kind: 'write:chat',
@ -44,6 +43,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
await this.chatService.react(ps.messageId, me.id, ps.reaction); await this.chatService.react(ps.messageId, me.id, ps.reaction);
}); });
} }

View File

@ -54,6 +54,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const room = await this.chatService.findRoomById(ps.roomId); const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -54,6 +54,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
if (ps.roomId != null) { if (ps.roomId != null) {
const room = await this.chatService.findRoomById(ps.roomId); const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) { if (room == null) {

View File

@ -50,6 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const message = await this.chatService.findMessageById(ps.messageId); const message = await this.chatService.findMessageById(ps.messageId);
if (message == null) { if (message == null) {
throw new ApiError(meta.errors.noSuchMessage); throw new ApiError(meta.errors.noSuchMessage);

View File

@ -13,7 +13,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
kind: 'write:chat', kind: 'write:chat',
@ -44,6 +43,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
await this.chatService.unreact(ps.messageId, me.id, ps.reaction); await this.chatService.unreact(ps.messageId, me.id, ps.reaction);
}); });
} }

View File

@ -56,6 +56,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService, private getterService: GetterService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const other = await this.getterService.getUser(ps.userId).catch(err => { const other = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err; throw err;

View File

@ -15,7 +15,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true, prohibitMoved: true,
@ -52,6 +51,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
const room = await this.chatService.createRoom(me, { const room = await this.chatService.createRoom(me, {
name: ps.name, name: ps.name,
description: ps.description ?? '', description: ps.description ?? '',

View File

@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
const room = await this.chatService.findRoomById(ps.roomId); const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -15,7 +15,6 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true, prohibitMoved: true,
@ -57,6 +56,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
const room = await this.chatService.findMyRoomById(me.id, ps.roomId); const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
await this.chatService.ignoreRoomInvitation(me.id, ps.roomId); await this.chatService.ignoreRoomInvitation(me.id, ps.roomId);
}); });
} }

View File

@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
return this.chatEntityService.packRoomInvitations(invitations, me); return this.chatEntityService.packRoomInvitations(invitations, me);
}); });

View File

@ -55,6 +55,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const room = await this.chatService.findMyRoomById(me.id, ps.roomId); const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
await this.chatService.joinToRoom(me.id, ps.roomId); await this.chatService.joinToRoom(me.id, ps.roomId);
}); });
} }

View File

@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId);
return this.chatEntityService.packRoomMemberships(memberships, me, { return this.chatEntityService.packRoomMemberships(memberships, me, {

View File

@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
await this.chatService.leaveRoom(me.id, ps.roomId); await this.chatService.leaveRoom(me.id, ps.roomId);
}); });
} }

View File

@ -54,6 +54,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const room = await this.chatService.findRoomById(ps.roomId); const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -43,6 +43,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
await this.chatService.muteRoom(me.id, ps.roomId, ps.mute); await this.chatService.muteRoom(me.id, ps.roomId, ps.mute);
}); });
} }

View File

@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
return this.chatEntityService.packRooms(rooms, me); return this.chatEntityService.packRooms(rooms, me);
}); });

View File

@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
const room = await this.chatService.findRoomById(ps.roomId); const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -49,6 +49,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatEntityService: ChatEntityService, private chatEntityService: ChatEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
const room = await this.chatService.findMyRoomById(me.id, ps.roomId); const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
if (room == null) { if (room == null) {
throw new ApiError(meta.errors.noSuchRoom); throw new ApiError(meta.errors.noSuchRoom);

View File

@ -108,7 +108,7 @@ export const ROLE_POLICIES = [
'canImportFollowing', 'canImportFollowing',
'canImportMuting', 'canImportMuting',
'canImportUserLists', 'canImportUserLists',
'canChat', 'chatAvailability',
] as const; ] as const;
export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg'; export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg';

View File

@ -114,6 +114,7 @@ export const navbarItemDef = reactive({
title: i18n.ts.chat, title: i18n.ts.chat,
icon: 'ti ti-messages', icon: 'ti ti-messages',
to: '/chat', to: '/chat',
show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'),
indicated: computed(() => $i != null && $i.hasUnreadChatMessages), indicated: computed(() => $i != null && $i.hasUnreadChatMessages),
}, },
achievements: { achievements: {

View File

@ -165,21 +165,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.canChat }}</template> <template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix> <template #suffix>
<span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> <span v-if="role.policies.chatAvailability.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span> <span v-else>{{ role.policies.chatAvailability.value === 'available' ? i18n.ts.yes : role.policies.chatAvailability.value === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span> <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.chatAvailability)"></i></span>
</template> </template>
<div class="_gaps"> <div class="_gaps">
<MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly"> <MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template> <template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly"> <MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template> <template #label>{{ i18n.ts.enable }}</template>
</MkSwitch> <option value="available">{{ i18n.ts.enabled }}</option>
<MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <option value="readonly">{{ i18n.ts.readonly }}</option>
<option value="unavailable">{{ i18n.ts.disabled }}</option>
</MkSelect>
<MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template> <template #label>{{ i18n.ts._role.priority }}</template>
</MkRange> </MkRange>
</div> </div>

View File

@ -51,12 +51,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.canChat }}</template> <template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template> <template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canChat"> <MkSelect v-model="policies.chatAvailability">
<template #label>{{ i18n.ts.enable }}</template> <template #label>{{ i18n.ts.enable }}</template>
</MkSwitch> <option value="available">{{ i18n.ts.enabled }}</option>
<option value="readonly">{{ i18n.ts.readonly }}</option>
<option value="unavailable">{{ i18n.ts.disabled }}</option>
</MkSelect>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
@ -295,6 +298,7 @@ import MkInput from '@/components/MkInput.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import MkRolePreview from '@/components/MkRolePreview.vue'; import MkRolePreview from '@/components/MkRolePreview.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';

View File

@ -85,7 +85,7 @@ const isMe = computed(() => props.message.fromUserId === $i.id);
const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
provide(DI.mfmEmojiReactCallback, (reaction) => { provide(DI.mfmEmojiReactCallback, (reaction) => {
if (!$i.policies.canChat) return; if ($i.policies.chatAvailability !== 'available') return;
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', { misskeyApi('chat/messages/react', {
@ -95,7 +95,7 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
}); });
function react(ev: MouseEvent) { function react(ev: MouseEvent) {
if (!$i.policies.canChat) return; if ($i.policies.chatAvailability !== 'available') return;
const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target); const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target);
if (!targetEl) return; if (!targetEl) return;
@ -110,7 +110,7 @@ function react(ev: MouseEvent) {
} }
function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) { function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) {
if (!$i.policies.canChat) return; if ($i.policies.chatAvailability !== 'available') return;
if (record.user.id === $i.id) { if (record.user.id === $i.id) {
misskeyApi('chat/messages/unreact', { misskeyApi('chat/messages/unreact', {
@ -138,7 +138,7 @@ function onContextmenu(ev: MouseEvent) {
function showMenu(ev: MouseEvent, contextmenu = false) { function showMenu(ev: MouseEvent, contextmenu = false) {
const menu: MenuItem[] = []; const menu: MenuItem[] = [];
if (!isMe.value && $i.policies.canChat) { if (!isMe.value && $i.policies.chatAvailability === 'available') {
menu.push({ menu.push({
text: i18n.ts.reaction, text: i18n.ts.reaction,
icon: 'ti ti-mood-plus', icon: 'ti ti-mood-plus',
@ -164,7 +164,7 @@ function showMenu(ev: MouseEvent, contextmenu = false) {
type: 'divider', type: 'divider',
}); });
if (isMe.value && $i.policies.canChat) { if (isMe.value && $i.policies.chatAvailability === 'available') {
menu.push({ menu.push({
text: i18n.ts.delete, text: i18n.ts.delete,
icon: 'ti ti-trash', icon: 'ti ti-trash',

View File

@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps"> <div class="_gaps">
<MkButton v-if="$i.policies.canChat" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> <MkButton v-if="$i.policies.chatAvailability === 'available'" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
<MkInfo v-else>{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> <MkInfo v-else>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<MkAd :preferForms="['horizontal', 'horizontal-big']"/> <MkAd :preferForms="['horizontal', 'horizontal-big']"/>

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions"> <PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions">
<MkSpacer v-if="tab === 'chat'" :contentMax="700"> <MkSpacer v-if="tab === 'chat'" :contentMax="700">
<div class="_gaps">
<div v-if="initializing"> <div v-if="initializing">
<MkLoading/> <MkLoading/>
</div> </div>
@ -53,7 +54,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo> <MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo>
</div> </div>
<MkInfo v-if="!$i.policies.canChat" warn>{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> <MkInfo v-if="$i.policies.chatAvailability !== 'available'" warn>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
</div>
</MkSpacer> </MkSpacer>
<MkSpacer v-else-if="tab === 'search'" :contentMax="700"> <MkSpacer v-else-if="tab === 'search'" :contentMax="700">

View File

@ -379,6 +379,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
<template v-if="$i.policies.chatAvailability !== 'unavailable'">
<SearchMarker :keywords="['chat', 'messaging']"> <SearchMarker :keywords="['chat', 'messaging']">
<MkFolder> <MkFolder>
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
@ -417,6 +418,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
</template>
<SearchMarker :keywords="['accessibility']"> <SearchMarker :keywords="['accessibility']">
<MkFolder> <MkFolder>
@ -732,6 +734,9 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { globalEvents } from '@/events.js'; import { globalEvents } from '@/events.js';
import { claimAchievement } from '@/utility/achievements.js'; import { claimAchievement } from '@/utility/achievements.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const lang = ref(miLocalStorage.getItem('lang')); const lang = ref(miLocalStorage.getItem('lang'));
const dataSaver = ref(prefer.s.dataSaver); const dataSaver = ref(prefer.s.dataSaver);

View File

@ -78,7 +78,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</SearchMarker> </SearchMarker>
<SearchMarker :keywords="['chat']">
<FormSection> <FormSection>
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
<div class="_gaps_m">
<MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<SearchMarker :keywords="['chat']"> <SearchMarker :keywords="['chat']">
<MkSelect v-model="chatScope" @update:modelValue="save()"> <MkSelect v-model="chatScope" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template>
@ -90,7 +95,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template>
</MkSelect> </MkSelect>
</SearchMarker> </SearchMarker>
</div>
</FormSection> </FormSection>
</SearchMarker>
<SearchMarker :keywords="['lockdown']"> <SearchMarker :keywords="['lockdown']">
<FormSection> <FormSection>

View File

@ -16,6 +16,10 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
errorComponent: MkError, errorComponent: MkError,
}); });
function chatPage(...args: Parameters<typeof page>) {
return $i?.policies.chatAvailability !== 'unavailable' ? page(...args) : page(() => import('@/pages/not-found.vue'));
}
export const ROUTE_DEF = [{ export const ROUTE_DEF = [{
path: '/@:username/pages/:pageName(*)', path: '/@:username/pages/:pageName(*)',
component: page(() => import('@/pages/page.vue')), component: page(() => import('@/pages/page.vue')),
@ -42,19 +46,19 @@ export const ROUTE_DEF = [{
component: page(() => import('@/pages/clip.vue')), component: page(() => import('@/pages/clip.vue')),
}, { }, {
path: '/chat', path: '/chat',
component: page(() => import('@/pages/chat/home.vue')), component: chatPage(() => import('@/pages/chat/home.vue')),
loginRequired: true, loginRequired: true,
}, { }, {
path: '/chat/user/:userId', path: '/chat/user/:userId',
component: page(() => import('@/pages/chat/room.vue')), component: chatPage(() => import('@/pages/chat/room.vue')),
loginRequired: true, loginRequired: true,
}, { }, {
path: '/chat/room/:roomId', path: '/chat/room/:roomId',
component: page(() => import('@/pages/chat/room.vue')), component: chatPage(() => import('@/pages/chat/room.vue')),
loginRequired: true, loginRequired: true,
}, { }, {
path: '/chat/messages/:messageId', path: '/chat/messages/:messageId',
component: page(() => import('@/pages/chat/message.vue')), component: chatPage(() => import('@/pages/chat/message.vue')),
loginRequired: true, loginRequired: true,
}, { }, {
path: '/instance-info/:host', path: '/instance-info/:host',

View File

@ -364,7 +364,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}, },
}); });
if ($i.policies.canChat && user.canChat && user.host == null) { if ($i.policies.chatAvailability === 'available' && user.canChat && user.host == null) {
menuItems.push({ menuItems.push({
type: 'link', type: 'link',
icon: 'ti ti-messages', icon: 'ti ti-messages',

View File

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2025.4.0-rc.1", "version": "2025.4.0-rc.2",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

View File

@ -5179,7 +5179,8 @@ export type components = {
canImportFollowing: boolean; canImportFollowing: boolean;
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
canChat: boolean; /** @enum {string} */
chatAvailability: 'available' | 'readonly' | 'unavailable';
}; };
ReversiGameLite: { ReversiGameLite: {
/** Format: id */ /** Format: id */