diff --git a/apps/api/prisma/migrations/20260930160000_conversation_mute_reactions/migration.sql b/apps/api/prisma/migrations/20260930160000_conversation_mute_reactions/migration.sql
new file mode 100644
index 0000000..5757c73
--- /dev/null
+++ b/apps/api/prisma/migrations/20260930160000_conversation_mute_reactions/migration.sql
@@ -0,0 +1,33 @@
+-- Mute per-user (smart: mentions can still notify)
+CREATE TABLE "ConversationRoomUserMute" (
+ "id" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "externalRoomId" TEXT NOT NULL,
+ "allowMentions" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ConversationRoomUserMute_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "ConversationRoomUserMute_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserMute"("organizationId", "userId", "externalRoomId");
+CREATE INDEX "ConversationRoomUserMute_organizationId_userId_idx" ON "ConversationRoomUserMute"("organizationId", "userId");
+ALTER TABLE "ConversationRoomUserMute" ADD CONSTRAINT "ConversationRoomUserMute_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Message emoji reactions
+CREATE TABLE "ConversationMessageReaction" (
+ "id" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "externalRoomId" TEXT NOT NULL,
+ "messageId" TEXT NOT NULL,
+ "emoji" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ConversationMessageReaction_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "ConversationMessageReaction_organizationId_externalRoomId_messageId_userId_emoji_key" ON "ConversationMessageReaction"("organizationId", "externalRoomId", "messageId", "userId", "emoji");
+CREATE INDEX "ConversationMessageReaction_organizationId_externalRoomId_idx" ON "ConversationMessageReaction"("organizationId", "externalRoomId");
+ALTER TABLE "ConversationMessageReaction" ADD CONSTRAINT "ConversationMessageReaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 87c54f5..65a0f4d 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -213,6 +213,8 @@ model Organization {
conversationPinnedMessages ConversationPinnedMessage[]
conversationRoomUserPins ConversationRoomUserPin[]
conversationRoomMembers ConversationRoomMember[]
+ conversationRoomUserMutes ConversationRoomUserMute[]
+ conversationMessageReactions ConversationMessageReaction[]
}
model User {
@@ -2161,3 +2163,34 @@ model ConversationRoomMember {
@@unique([organizationId, externalRoomId, userId])
@@index([organizationId, externalRoomId])
}
+
+/// بیصدا کردن گفتگو برای کاربر — با استثنای منشن
+model ConversationRoomUserMute {
+ id String @id @default(cuid())
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ userId String
+ externalRoomId String
+ /// اگر true باشد منشن همچنان اعلان/خواندهنشده میسازد
+ allowMentions Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([organizationId, userId, externalRoomId])
+ @@index([organizationId, userId])
+}
+
+/// واکنش ایموجی روی پیام
+model ConversationMessageReaction {
+ id String @id @default(cuid())
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ externalRoomId String
+ messageId String
+ emoji String
+ userId String
+ createdAt DateTime @default(now())
+
+ @@unique([organizationId, externalRoomId, messageId, userId, emoji])
+ @@index([organizationId, externalRoomId])
+}
diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts
index 11e226f..406e398 100644
--- a/apps/api/src/conversations/conversations.controller.ts
+++ b/apps/api/src/conversations/conversations.controller.ts
@@ -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 },
diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts
index da5dadf..caa1e17 100644
--- a/apps/api/src/conversations/conversations.service.ts
+++ b/apps/api/src/conversations/conversations.service.ts
@@ -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
+
+
+
ویرایششده
@@ -2033,6 +2245,17 @@ onUnmounted(() => {
در حال ضبط {{ toFaDigits(recordLabel()) }}
+
+
+
+
+
+
+