This commit is contained in:
syuilo
2025-03-22 16:23:16 +09:00
parent 381126bdc7
commit acac58e59c
17 changed files with 253 additions and 61 deletions

82
locales/index.d.ts vendored
View File

@@ -5358,31 +5358,63 @@ export interface Locale extends ILocale {
* チャット
*/
"chat": string;
/**
* 個人チャット
*/
"individualChat": string;
/**
* 特定ユーザーとの一対一のチャットができます。
*/
"individualChat_description": string;
/**
* ルームチャット
*/
"roomChat": string;
/**
* 複数人でのチャットができます。
* また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。
*/
"roomChat_description": string;
/**
* このユーザーとのチャットを開始できません
*/
"cannotChatWithTheUser": string;
/**
* チャットが使えない状態になっているか、相手がチャットを開放していません。
*/
"cannotChatWithTheUser_description": string;
"_chat": {
/**
* 個人チャット
*/
"individualChat": string;
/**
* 特定ユーザーとの一対一のチャットができます。
*/
"individualChat_description": string;
/**
* ルームチャット
*/
"roomChat": string;
/**
* 複数人でのチャットができます。
* また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。
*/
"roomChat_description": string;
/**
* このユーザーとのチャットを開始できません
*/
"cannotChatWithTheUser": string;
/**
* チャットが使えない状態になっているか、相手がチャットを開放していません。
*/
"cannotChatWithTheUser_description": string;
/**
* チャットを許可する相手
*/
"chatAllowedUsers": string;
/**
* 自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。
*/
"chatAllowedUsers_note": string;
"_chatAllowedUsers": {
/**
* 誰でも
*/
"everyone": string;
/**
* 自分のフォロワーのみ
*/
"followers": string;
/**
* 自分がフォローしているユーザーのみ
*/
"following": string;
/**
* 相互フォローのユーザーのみ
*/
"mutual": string;
/**
* 誰も許可しない
*/
"none": string;
};
};
"_emojiPalette": {
/**
* パレット

View File

@@ -1335,12 +1335,23 @@ postForm: "投稿フォーム"
textCount: "文字数"
information: "情報"
chat: "チャット"
individualChat: "個人チャット"
individualChat_description: "特定ユーザーとの一対一のチャットができます。"
roomChat: "ルームチャット"
roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
_chat:
individualChat: "個人チャット"
individualChat_description: "特定ユーザーとの一対一のチャットができます。"
roomChat: "ルームチャット"
roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
chatAllowedUsers: "チャットを許可する相手"
chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。"
_chatAllowedUsers:
everyone: "誰でも"
followers: "自分のフォロワーのみ"
following: "自分がフォローしているユーザーのみ"
mutual: "相互フォローのユーザーのみ"
none: "誰も許可しない"
_emojiPalette:
palettes: "パレット"

View File

@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Chat31742617546147 {
name = 'Chat31742617546147'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "chat_approval" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "otherId" character varying(32) NOT NULL, CONSTRAINT "PK_fbbb95d60acf5c85388345b5f5d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_530257863e1381a7f2f1d3282f" ON "chat_approval" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_b1d46037f23d170da5c05fdf75" ON "chat_approval" ("otherId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_12c4768a2f706fc267f2078903" ON "chat_approval" ("userId", "otherId") `);
await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_530257863e1381a7f2f1d3282fe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_b1d46037f23d170da5c05fdf755" FOREIGN KEY ("otherId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_b1d46037f23d170da5c05fdf755"`);
await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_530257863e1381a7f2f1d3282fe"`);
await queryRunner.query(`DROP INDEX "public"."IDX_12c4768a2f706fc267f2078903"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b1d46037f23d170da5c05fdf75"`);
await queryRunner.query(`DROP INDEX "public"."IDX_530257863e1381a7f2f1d3282f"`);
await queryRunner.query(`DROP TABLE "chat_approval"`);
}
}

View File

@@ -16,7 +16,7 @@ import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityServi
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';
import { bindThis } from '@/decorators.js';
import type { ChatMessagesRepository, MiChatMessage, MiChatRoom, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
@@ -37,6 +37,15 @@ export class ChatService {
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
@Inject(DI.chatApprovalsRepository)
private chatApprovalsRepository: ChatApprovalsRepository,
@Inject(DI.chatRoomsRepository)
private chatRoomsRepository: ChatRoomsRepository,
@Inject(DI.chatRoomMembershipsRepository)
private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@@ -64,22 +73,39 @@ export class ChatService {
throw new Error('yourself');
}
if (toUser.chatScope === 'none') {
throw new Error('recipient is cannot chat');
} else if (toUser.chatScope === 'followers') {
const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id);
if (!isFollower) {
throw new Error('recipient is cannot chat');
}
} else if (toUser.chatScope === 'following') {
const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id);
if (!isFollowing) {
throw new Error('recipient is cannot chat');
}
} else if (toUser.chatScope === 'mutual') {
const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
if (!isMutual) {
const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval')
.where(new Brackets(qb => { // 自分が相手を許可しているか
qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id })
.andWhere('approval.otherId = :toUserId', { toUserId: toUser.id });
}))
.orWhere(new Brackets(qb => { // 相手が自分を許可しているか
qb.where('approval.userId = :toUserId', { toUserId: toUser.id })
.andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id });
}))
.take(2)
.getMany();
const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id);
const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id);
if (!otherApprovedMe) {
if (toUser.chatScope === 'none') {
throw new Error('recipient is cannot chat');
} else if (toUser.chatScope === 'followers') {
const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id);
if (!isFollower) {
throw new Error('recipient is cannot chat');
}
} else if (toUser.chatScope === 'following') {
const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id);
if (!isFollowing) {
throw new Error('recipient is cannot chat');
}
} else if (toUser.chatScope === 'mutual') {
const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
if (!isMutual) {
throw new Error('recipient is cannot chat');
}
}
}
@@ -104,6 +130,15 @@ export class ChatService {
const inserted = await this.chatMessagesRepository.insertOne(message);
// 相手を許可しておく
if (!iApprovedOther) {
this.chatApprovalsRepository.insertOne({
id: this.idService.gen(),
userId: fromUser.id,
otherId: toUser.id,
});
}
const packedMessage = await this.chatMessageEntityService.packLite(inserted);
if (this.userEntityService.isLocalUser(toUser)) {

View File

@@ -558,6 +558,7 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope,
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,
name: role.name,

View File

@@ -84,6 +84,7 @@ export const DI = {
flashLikesRepository: Symbol('flashLikesRepository'),
userMemosRepository: Symbol('userMemosRepository'),
chatMessagesRepository: Symbol('chatMessagesRepository'),
chatApprovalsRepository: Symbol('chatApprovalsRepository'),
chatRoomsRepository: Symbol('chatRoomsRepository'),
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('chat_approval')
@Index(['userId', 'otherId'], { unique: true })
export class MiChatApproval {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Index()
@Column({
...id(),
})
public otherId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public other: MiUser | null;
}

View File

@@ -81,6 +81,7 @@ import {
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiChatApproval,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@@ -511,6 +512,12 @@ const $chatRoomMembershipsRepository: Provider = {
inject: [DI.db],
};
const $chatApprovalsRepository: Provider = {
provide: DI.chatApprovalsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChatApproval),
inject: [DI.db],
};
const $bubbleGameRecordsRepository: Provider = {
provide: DI.bubbleGameRecordsRepository,
useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository<MiBubbleGameRecord>),
@@ -597,6 +604,7 @@ const $reversiGamesRepository: Provider = {
$chatMessagesRepository,
$chatRoomsRepository,
$chatRoomMembershipsRepository,
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
],
@@ -672,6 +680,7 @@ const $reversiGamesRepository: Provider = {
$chatMessagesRepository,
$chatRoomsRepository,
$chatRoomMembershipsRepository,
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
],

View File

@@ -78,6 +78,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@@ -197,6 +198,7 @@ export {
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
};
@@ -272,5 +274,6 @@ export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMem
export type ChatMessagesRepository = Repository<MiChatMessage> & MiRepository<MiChatMessage>;
export type ChatRoomsRepository = Repository<MiChatRoom> & MiRepository<MiChatRoom>;
export type ChatRoomMembershipsRepository = Repository<MiChatRoomMembership> & MiRepository<MiChatRoomMembership>;
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;

View File

@@ -358,6 +358,11 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
enum: ['public', 'followers', 'private'],
},
chatScope: {
type: 'string',
nullable: false, optional: false,
enum: ['everyone', 'following', 'followers', 'mutual', 'none'],
},
roles: {
type: 'array',
nullable: false, optional: false,

View File

@@ -8,6 +8,9 @@ import pg from 'pg';
import { DataSource, Logger } from 'typeorm';
import * as highlight from 'cli-highlight';
import { entities as charts } from '@/core/chart/entities.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
@@ -81,11 +84,8 @@ import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MiSystemAccount } from './models/SystemAccount.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiSystemAccount } from '@/models/SystemAccount.js';
pg.types.setTypeParser(20, Number);
@@ -242,6 +242,7 @@ export const entities = [
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
...charts,

View File

@@ -190,6 +190,7 @@ export const paramDef = {
autoSensitive: { type: 'boolean' },
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: muteWords,
hardMutedWords: muteWords,
@@ -288,6 +289,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
// TODO: ちゃんと数える

View File

@@ -15,7 +15,7 @@ describe('ユーザー', () => {
// エンティティとしてのユーザーを主眼においたテストを記述する
// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
const stripUndefined = <T extends { [key: string]: any } >(orig: T): Partial<T> => {
return Object.entries({ ...orig })
.filter(([, value]) => value !== undefined)
.reduce((obj: Partial<T>, [key, value]) => {
@@ -83,6 +83,7 @@ describe('ユーザー', () => {
publicReactions: user.publicReactions,
followingVisibility: user.followingVisibility,
followersVisibility: user.followersVisibility,
chatScope: user.chatScope,
roles: user.roles,
memo: user.memo,
});
@@ -343,6 +344,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.followingVisibility, 'public');
assert.strictEqual(response.followersVisibility, 'public');
assert.strictEqual(response.chatScope, 'mutual');
assert.deepStrictEqual(response.roles, []);
assert.strictEqual(response.memo, null);

View File

@@ -66,13 +66,13 @@ const history = ref<{
function start(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.individualChat,
caption: i18n.ts.individualChat_description,
text: i18n.ts._chat.individualChat,
caption: i18n.ts._chat.individualChat_description,
icon: 'ti ti-user',
action: () => { startUser(); },
}, { type: 'divider' }, {
text: i18n.ts.roomChat,
caption: i18n.ts.roomChat_description,
text: i18n.ts._chat.roomChat,
caption: i18n.ts._chat.roomChat_description,
icon: 'ti ti-users',
action: () => { startRoom(); },
}], ev.currentTarget ?? ev.target);

View File

@@ -78,6 +78,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</SearchMarker>
<FormSection>
<SearchMarker :keywords="['chat']">
<MkSelect v-model="chatScope" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template>
<option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option>
<option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option>
<option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option>
<option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option>
<option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option>
<template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template>
</MkSelect>
</SearchMarker>
</FormSection>
<SearchMarker :keywords="['lockdown']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
@@ -208,6 +222,7 @@ const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions);
const followingVisibility = ref($i.followingVisibility);
const followersVisibility = ref($i.followersVisibility);
const chatScope = ref($i.chatScope);
const makeNotesFollowersOnlyBefore_type = computed(() => {
if (makeNotesFollowersOnlyBefore.value == null) {
@@ -260,6 +275,7 @@ function save() {
publicReactions: !!publicReactions.value,
followingVisibility: followingVisibility.value,
followersVisibility: followersVisibility.value,
chatScope: chatScope.value,
});
}

View File

@@ -240,20 +240,25 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['explore', i18n.ts.makeExplorableDescription],
},
{
id: '7vr04wKol',
id: 'xEYlOghao',
label: i18n.ts._chat.chatAllowedUsers,
keywords: ['chat'],
},
{
id: 'BnOtlyaAh',
children: [
{
id: 'Av7fAaHv8',
id: 'BzMIVBpL0',
label: i18n.ts._accountSettings.requireSigninToViewContents,
keywords: ['login', 'signin'],
},
{
id: '5RbESWefG',
id: 'jJUqPqBAv',
label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
},
{
id: 'hdzwDs3qd',
id: 'ra10txIFV',
label: i18n.ts._accountSettings.makeNotesHiddenBefore,
keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
},

View File

@@ -3875,6 +3875,8 @@ export type components = {
followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
chatScope: 'everyone' | 'following' | 'followers' | 'mutual' | 'none';
roles: components['schemas']['RoleLite'][];
followedMessage?: string | null;
memo: string | null;
@@ -21330,6 +21332,8 @@ export type operations = {
followingVisibility?: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility?: 'public' | 'followers' | 'private';
/** @enum {string} */
chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
/** Format: misskey:id */
pinnedPageId?: string | null;
mutedWords?: (string[] | string)[];