Edit text and links, mute and unmute chat

This commit is contained in:
2026-09-30 14:58:44 +03:30
parent d3986da28b
commit 75b83b7f1b
9 changed files with 1070 additions and 51 deletions
@@ -15,6 +15,8 @@ import {
RemoveConversationRoomMemberDto,
UpdateConversationRoomMemberRoleDto,
ConversationTypingDto,
MuteConversationRoomDto,
ToggleMessageReactionDto,
} from './dto';
import { ConversationsService } from './conversations.service';
@@ -186,6 +188,49 @@ export class ConversationsController {
);
}
@Post('rooms/mute')
muteRoom(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: MuteConversationRoomDto,
) {
return this.conversations.setRoomMuted(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.muted,
dto.allowMentions !== false,
);
}
@Get('messages/reactions')
listReactions(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Query('externalRoomId') externalRoomId: string,
) {
return this.conversations.listMessageReactions(
user.userId,
user.organizationId,
user.role,
externalRoomId || '',
);
}
@Post('messages/reactions')
toggleReaction(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: ToggleMessageReactionDto,
) {
return this.conversations.toggleMessageReaction(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.messageId,
dto.emoji,
);
}
@Post('messages/hide')
hideMessages(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@@ -312,6 +312,7 @@ export class ConversationsService {
});
const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t]));
const pinnedIds = await this.userPinnedRoomIds(organizationId, userId);
const muteMap = await this.userMuteMap(organizationId, userId);
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { role: true },
@@ -327,9 +328,12 @@ export class ConversationsService {
const isRoomAdmin =
roomAdminIds.has(dto.externalRoomId) ||
trackedByExternal.get(dto.externalRoomId)?.createdByUserId === userId;
const mute = muteMap.get(dto.externalRoomId);
return {
...dto,
pinned: pinnedIds.has(dto.externalRoomId),
muted: Boolean(mute),
muteAllowMentions: mute ? mute.allowMentions : true,
canPost:
dto.kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin),
canManageMembers:
@@ -343,6 +347,7 @@ export class ConversationsService {
const kind = this.normalizeKind(t.kind);
const isRoomAdmin =
roomAdminIds.has(t.externalRoomId) || t.createdByUserId === userId;
const mute = muteMap.get(t.externalRoomId);
return {
id: t.id,
externalRoomId: t.externalRoomId,
@@ -362,6 +367,8 @@ export class ConversationsService {
: null
: null,
pinned: pinnedIds.has(t.externalRoomId),
muted: Boolean(mute),
muteAllowMentions: mute ? mute.allowMentions : true,
canPost: kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin),
canManageMembers: kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin),
};
@@ -683,7 +690,7 @@ export class ConversationsService {
let preview = lastMsg
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
: '';
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview)) {
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview) || /^__MENTO_REACT__:/i.test(preview)) {
preview = '';
}
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
@@ -745,6 +752,18 @@ export class ConversationsService {
return new Set(rows.map((r) => r.externalRoomId));
}
private async userMuteMap(
organizationId: string,
userId: string,
): Promise<Map<string, { allowMentions: boolean }>> {
const rows = await this.prisma.conversationRoomUserMute.findMany({
where: { organizationId, userId },
select: { externalRoomId: true, allowMentions: true },
take: 2000,
});
return new Map(rows.map((r) => [r.externalRoomId, { allowMentions: r.allowMentions }]));
}
private async userAdminRoomIds(organizationId: string, userId: string): Promise<Set<string>> {
const rows = await this.prisma.conversationRoomMember.findMany({
where: { organizationId, userId, role: 'admin' },
@@ -1048,6 +1067,129 @@ export class ConversationsService {
return { externalRoomId: roomId, pinned };
}
async setRoomMuted(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
muted: boolean,
allowMentions = true,
): Promise<{ externalRoomId: string; muted: boolean; muteAllowMentions: boolean }> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
if (muted) {
await this.prisma.conversationRoomUserMute.upsert({
where: {
organizationId_userId_externalRoomId: {
organizationId,
userId,
externalRoomId: roomId,
},
},
create: {
organizationId,
userId,
externalRoomId: roomId,
allowMentions: allowMentions !== false,
},
update: { allowMentions: allowMentions !== false },
});
} else {
await this.prisma.conversationRoomUserMute.deleteMany({
where: { organizationId, userId, externalRoomId: roomId },
});
}
return {
externalRoomId: roomId,
muted,
muteAllowMentions: muted ? allowMentions !== false : true,
};
}
async listMessageReactions(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
): Promise<
Array<{ messageId: string; emoji: string; count: number; me: boolean }>
> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
if (!roomId) return [];
const rows = await this.prisma.conversationMessageReaction.findMany({
where: { organizationId, externalRoomId: roomId },
select: { messageId: true, emoji: true, userId: true },
take: 5000,
});
const map = new Map<string, { messageId: string; emoji: string; count: number; me: boolean }>();
for (const r of rows) {
const key = `${r.messageId}\0${r.emoji}`;
const cur = map.get(key) || {
messageId: r.messageId,
emoji: r.emoji,
count: 0,
me: false,
};
cur.count += 1;
if (r.userId === userId) cur.me = true;
map.set(key, cur);
}
return [...map.values()];
}
async toggleMessageReaction(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
messageId: string,
emoji: string,
): Promise<{
added: boolean;
reactions: Array<{ messageId: string; emoji: string; count: number; me: boolean }>;
}> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
const mid = messageId.trim();
const em = emoji.trim().slice(0, 16);
if (!roomId || !mid || !em) throw new BadRequestException('پارامتر واکنش ناقص است');
const existing = await this.prisma.conversationMessageReaction.findFirst({
where: {
organizationId,
externalRoomId: roomId,
messageId: mid,
userId,
emoji: em,
},
});
let added = false;
if (existing) {
await this.prisma.conversationMessageReaction.delete({ where: { id: existing.id } });
} else {
await this.prisma.conversationMessageReaction.create({
data: {
organizationId,
externalRoomId: roomId,
messageId: mid,
userId,
emoji: em,
},
});
added = true;
}
const reactions = await this.listMessageReactions(
userId,
organizationId,
role,
roomId,
);
return { added, reactions };
}
async getPinnedMessage(
userId: string,
organizationId: string,
+31
View File
@@ -169,3 +169,34 @@ export class ConversationTypingDto {
@MaxLength(200)
externalRoomId!: string;
}
export class MuteConversationRoomDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsBoolean()
muted!: boolean;
@IsOptional()
@IsBoolean()
allowMentions?: boolean;
}
export class ToggleMessageReactionDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsString()
@MinLength(1)
@MaxLength(200)
messageId!: string;
@IsString()
@MinLength(1)
@MaxLength(16)
emoji!: string;
}