Add support for chat message reactions.

It introduces the graphql prop `reactions` to the type `chat_message_public` and `chat_message_private`.
It also add two mutations `chatSendMessageReaction` and `chatDeleteMessageReaction`.
This commit is contained in:
Gustavo Trott 2024-10-03 14:34:24 -03:00
parent b3403ffdf5
commit e19cbdbb58
18 changed files with 400 additions and 0 deletions

View File

@ -0,0 +1,69 @@
package org.bigbluebutton.core.apps.groupchats
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.PermissionCheck
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.db.ChatMessageReactionDAO
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models.{ Roles, Users2x }
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting }
import org.bigbluebutton.core2.MeetingStatus2x
trait DeleteGroupChatMessageReactionReqMsgHdlr extends HandlerHelpers {
this: GroupChatHdlrs =>
def handle(msg: DeleteGroupChatMessageReactionReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): MeetingState2x = {
val chatDisabled: Boolean = liveMeeting.props.meetingProp.disabledFeatures.contains("chat")
var chatLocked: Boolean = false
var chatLockedForUser: Boolean = false
var newState = state
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId)
groupChat <- state.groupChats.find(msg.body.chatId)
} yield {
if (groupChat.access == GroupChatAccess.PUBLIC && user.userLockSettings.disablePublicChat && user.role != Roles.MODERATOR_ROLE) {
chatLockedForUser = true
}
val userIsModerator = user.role != Roles.MODERATOR_ROLE
if (!userIsModerator && user.locked) {
val permissions = MeetingStatus2x.getPermissions(liveMeeting.status)
if (groupChat.access == GroupChatAccess.PRIVATE) {
val modMembers = groupChat.users.filter(cu => Users2x.findWithIntId(liveMeeting.users2x, cu.id) match {
case Some(user) => user.role == Roles.MODERATOR_ROLE
case None => false
})
// don't lock private chats that involve a moderator
if (modMembers.isEmpty) {
chatLocked = permissions.disablePrivChat
}
} else {
chatLocked = permissions.disablePubChat
}
}
if (!chatDisabled && !(applyPermissionCheck && chatLocked) && !chatLockedForUser) {
for {
gcMessage <- groupChat.msgs.find(gcm => gcm.id == msg.body.messageId)
} yield {
val chatIsPrivate = groupChat.access == GroupChatAccess.PRIVATE
val userIsAParticipant = groupChat.users.exists(u => u.id == user.intId)
if ((chatIsPrivate && userIsAParticipant) || !chatIsPrivate) {
val event = buildGroupChatMessageReactionDeletedEvtMsg(liveMeeting.props.meetingProp.intId, msg.header.userId, msg.body.chatId, gcMessage.id, msg.body.reactionEmoji)
bus.outGW.send(event)
ChatMessageReactionDAO.insert(liveMeeting.props.meetingProp.intId, gcMessage.id, msg.header.userId, msg.body.reactionEmoji)
} else {
val reason = "User isn't a participant of the chat"
PermissionCheck.ejectUserForFailedPermission(msg.header.meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
}
}
}
}
newState
}
}

View File

@ -11,6 +11,8 @@ class GroupChatHdlrs(implicit val context: ActorContext)
with SendGroupChatMessageMsgHdlr with SendGroupChatMessageMsgHdlr
with EditGroupChatMessageReqMsgHdlr with EditGroupChatMessageReqMsgHdlr
with DeleteGroupChatMessageReqMsgHdlr with DeleteGroupChatMessageReqMsgHdlr
with SendGroupChatMessageReactionReqMsgHdlr
with DeleteGroupChatMessageReactionReqMsgHdlr
with SendGroupChatMessageFromApiSysPubMsgHdlr with SendGroupChatMessageFromApiSysPubMsgHdlr
with SetGroupChatVisibleReqMsgHdlr with SetGroupChatVisibleReqMsgHdlr
with SetGroupChatLastSeenReqMsgHdlr { with SetGroupChatLastSeenReqMsgHdlr {

View File

@ -0,0 +1,69 @@
package org.bigbluebutton.core.apps.groupchats
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.PermissionCheck
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.db.ChatMessageReactionDAO
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models.{ Roles, Users2x }
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting }
import org.bigbluebutton.core2.MeetingStatus2x
trait SendGroupChatMessageReactionReqMsgHdlr extends HandlerHelpers {
this: GroupChatHdlrs =>
def handle(msg: SendGroupChatMessageReactionReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): MeetingState2x = {
val chatDisabled: Boolean = liveMeeting.props.meetingProp.disabledFeatures.contains("chat")
var chatLocked: Boolean = false
var chatLockedForUser: Boolean = false
var newState = state
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId)
groupChat <- state.groupChats.find(msg.body.chatId)
} yield {
if (groupChat.access == GroupChatAccess.PUBLIC && user.userLockSettings.disablePublicChat && user.role != Roles.MODERATOR_ROLE) {
chatLockedForUser = true
}
val userIsModerator = user.role != Roles.MODERATOR_ROLE
if (!userIsModerator && user.locked) {
val permissions = MeetingStatus2x.getPermissions(liveMeeting.status)
if (groupChat.access == GroupChatAccess.PRIVATE) {
val modMembers = groupChat.users.filter(cu => Users2x.findWithIntId(liveMeeting.users2x, cu.id) match {
case Some(user) => user.role == Roles.MODERATOR_ROLE
case None => false
})
// don't lock private chats that involve a moderator
if (modMembers.isEmpty) {
chatLocked = permissions.disablePrivChat
}
} else {
chatLocked = permissions.disablePubChat
}
}
if (!chatDisabled && !(applyPermissionCheck && chatLocked) && !chatLockedForUser) {
for {
gcMessage <- groupChat.msgs.find(gcm => gcm.id == msg.body.messageId)
} yield {
val chatIsPrivate = groupChat.access == GroupChatAccess.PRIVATE
val userIsAParticipant = groupChat.users.exists(u => u.id == user.intId)
if ((chatIsPrivate && userIsAParticipant) || !chatIsPrivate) {
val event = buildGroupChatMessageReactionSentEvtMsg(liveMeeting.props.meetingProp.intId, msg.header.userId, msg.body.chatId, gcMessage.id, msg.body.reactionEmoji)
bus.outGW.send(event)
ChatMessageReactionDAO.insert(liveMeeting.props.meetingProp.intId, gcMessage.id, msg.header.userId, msg.body.reactionEmoji)
} else {
val reason = "User isn't a participant of the chat"
PermissionCheck.ejectUserForFailedPermission(msg.header.meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
}
}
}
}
newState
}
}

View File

@ -0,0 +1,51 @@
package org.bigbluebutton.core.db
import slick.jdbc.PostgresProfile.api._
import slick.lifted.{ ProvenShape }
case class ChatMessageReactionDbModel(
meetingId: String,
messageId: String,
userId: String,
reactionEmoji: String,
createdAt: java.sql.Timestamp
)
class ChatMessageReactionDbTableDef(tag: Tag) extends Table[ChatMessageReactionDbModel](tag, "chat_message_reaction") {
val meetingId = column[String]("meetingId")
val messageId = column[String]("messageId", O.PrimaryKey)
val userId = column[String]("userId", O.PrimaryKey)
val reactionEmoji = column[String]("reactionEmoji", O.PrimaryKey)
val createdAt = column[java.sql.Timestamp]("createdAt")
override def * : ProvenShape[ChatMessageReactionDbModel] = (meetingId, messageId, userId, reactionEmoji, createdAt) <> (ChatMessageReactionDbModel.tupled, ChatMessageReactionDbModel.unapply)
}
object ChatMessageReactionDAO {
def insert(meetingId: String, messageId: String, userId: String, reactionEmoji: String) = {
DatabaseConnection.enqueue(
TableQuery[ChatMessageReactionDbTableDef].forceInsert(
ChatMessageReactionDbModel(
meetingId = meetingId,
messageId = messageId,
userId = userId,
reactionEmoji = reactionEmoji,
createdAt = new java.sql.Timestamp(System.currentTimeMillis())
)
)
)
}
def delete(meetingId: String, messageId: String, userId: String, reactionEmoji: String) = {
DatabaseConnection.enqueue(
TableQuery[ChatMessageReactionDbTableDef]
.filter(_.meetingId === meetingId)
.filter(_.messageId === messageId)
.filter(_.userId === userId)
.filter(_.reactionEmoji === reactionEmoji)
.delete
)
}
}

View File

@ -410,6 +410,10 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[EditGroupChatMessageReqMsg](envelope, jsonNode) routeGenericMsg[EditGroupChatMessageReqMsg](envelope, jsonNode)
case DeleteGroupChatMessageReqMsg.NAME => case DeleteGroupChatMessageReqMsg.NAME =>
routeGenericMsg[DeleteGroupChatMessageReqMsg](envelope, jsonNode) routeGenericMsg[DeleteGroupChatMessageReqMsg](envelope, jsonNode)
case SendGroupChatMessageReactionReqMsg.NAME =>
routeGenericMsg[SendGroupChatMessageReactionReqMsg](envelope, jsonNode)
case DeleteGroupChatMessageReactionReqMsg.NAME =>
routeGenericMsg[DeleteGroupChatMessageReactionReqMsg](envelope, jsonNode)
case GetGroupChatMsgsReqMsg.NAME => case GetGroupChatMsgsReqMsg.NAME =>
routeGenericMsg[GetGroupChatMsgsReqMsg](envelope, jsonNode) routeGenericMsg[GetGroupChatMsgsReqMsg](envelope, jsonNode)
case CreateGroupChatReqMsg.NAME => case CreateGroupChatReqMsg.NAME =>

View File

@ -327,4 +327,22 @@ trait HandlerHelpers extends SystemConfiguration {
BbbCommonEnvCoreMsg(envelope, event) BbbCommonEnvCoreMsg(envelope, event)
} }
def buildGroupChatMessageReactionSentEvtMsg(meetingId: String, userId: String, chatId: String, messageId: String, reactionEmoji: String): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
val envelope = BbbCoreEnvelope(GroupChatMessageReactionSentEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(GroupChatMessageReactionSentEvtMsg.NAME, meetingId, userId)
val body = GroupChatMessageReactionSentEvtMsgBody(chatId, messageId, reactionEmoji)
val event = GroupChatMessageReactionSentEvtMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildGroupChatMessageReactionDeletedEvtMsg(meetingId: String, userId: String, chatId: String, messageId: String, reactionEmoji: String): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
val envelope = BbbCoreEnvelope(GroupChatMessageReactionDeletedEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(GroupChatMessageReactionDeletedEvtMsg.NAME, meetingId, userId)
val body = GroupChatMessageReactionDeletedEvtMsgBody(chatId, messageId, reactionEmoji)
val event = GroupChatMessageReactionDeletedEvtMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
} }

View File

@ -711,6 +711,12 @@ class MeetingActor(
case m: DeleteGroupChatMessageReqMsg => case m: DeleteGroupChatMessageReqMsg =>
state = groupChatApp.handle(m, state, liveMeeting, msgBus) state = groupChatApp.handle(m, state, liveMeeting, msgBus)
updateUserLastActivity(m.header.userId) updateUserLastActivity(m.header.userId)
case m: SendGroupChatMessageReactionReqMsg =>
state = groupChatApp.handle(m, state, liveMeeting, msgBus)
updateUserLastActivity(m.header.userId)
case m: DeleteGroupChatMessageReactionReqMsg =>
state = groupChatApp.handle(m, state, liveMeeting, msgBus)
updateUserLastActivity(m.header.userId)
// Plugin // Plugin
case m: PluginDataChannelPushEntryMsg => pluginHdlrs.handle(m, state, liveMeeting) case m: PluginDataChannelPushEntryMsg => pluginHdlrs.handle(m, state, liveMeeting)

View File

@ -138,6 +138,8 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
case m: SendGroupChatMessageMsg => logChatMessage(msg) case m: SendGroupChatMessageMsg => logChatMessage(msg)
case m: EditGroupChatMessageReqMsg => logChatMessage(msg) case m: EditGroupChatMessageReqMsg => logChatMessage(msg)
case m: DeleteGroupChatMessageReqMsg => logChatMessage(msg) case m: DeleteGroupChatMessageReqMsg => logChatMessage(msg)
case m: DeleteGroupChatMessageReqMsg => logChatMessage(msg)
case m: DeleteGroupChatMessageReqMsg => logChatMessage(msg)
case m: GroupChatMessageBroadcastEvtMsg => logChatMessage(msg) case m: GroupChatMessageBroadcastEvtMsg => logChatMessage(msg)
case m: GetGroupChatMsgsReqMsg => logChatMessage(msg) case m: GetGroupChatMsgsReqMsg => logChatMessage(msg)
case m: GetGroupChatMsgsRespMsg => logChatMessage(msg) case m: GetGroupChatMsgsRespMsg => logChatMessage(msg)

View File

@ -135,6 +135,22 @@ object GroupChatMessageDeletedEvtMsg { val NAME = "GroupChatMessageDeletedEvtMsg
case class GroupChatMessageDeletedEvtMsg(header: BbbClientMsgHeader, body: GroupChatMessageDeletedEvtMsgBody) extends BbbCoreMsg case class GroupChatMessageDeletedEvtMsg(header: BbbClientMsgHeader, body: GroupChatMessageDeletedEvtMsgBody) extends BbbCoreMsg
case class GroupChatMessageDeletedEvtMsgBody(chatId: String, messageId: String) case class GroupChatMessageDeletedEvtMsgBody(chatId: String, messageId: String)
object SendGroupChatMessageReactionReqMsg { val NAME = "SendGroupChatMessageReactionReqMsg" }
case class SendGroupChatMessageReactionReqMsg(header: BbbClientMsgHeader, body: SendGroupChatMessageReactionReqMsgBody) extends StandardMsg
case class SendGroupChatMessageReactionReqMsgBody(chatId: String, messageId: String, reactionEmoji: String)
object GroupChatMessageReactionSentEvtMsg { val NAME = "GroupChatMessageReactionSentEvtMsg" }
case class GroupChatMessageReactionSentEvtMsg(header: BbbClientMsgHeader, body: GroupChatMessageReactionSentEvtMsgBody) extends BbbCoreMsg
case class GroupChatMessageReactionSentEvtMsgBody(chatId: String, messageId: String, reactionEmoji: String)
object DeleteGroupChatMessageReactionReqMsg { val NAME = "DeleteGroupChatMessageReactionReqMsg" }
case class DeleteGroupChatMessageReactionReqMsg(header: BbbClientMsgHeader, body: DeleteGroupChatMessageReactionReqMsgBody) extends StandardMsg
case class DeleteGroupChatMessageReactionReqMsgBody(chatId: String, messageId: String, reactionEmoji: String)
object GroupChatMessageReactionDeletedEvtMsg { val NAME = "GroupChatMessageReactionDeletedEvtMsg" }
case class GroupChatMessageReactionDeletedEvtMsg(header: BbbClientMsgHeader, body: GroupChatMessageReactionDeletedEvtMsgBody) extends BbbCoreMsg
case class GroupChatMessageReactionDeletedEvtMsgBody(chatId: String, messageId: String, reactionEmoji: String)
object UserTypingPubMsg { val NAME = "UserTypingPubMsg" } object UserTypingPubMsg { val NAME = "UserTypingPubMsg" }
case class UserTypingPubMsg(header: BbbClientMsgHeader, body: UserTypingPubMsgBody) extends StandardMsg case class UserTypingPubMsg(header: BbbClientMsgHeader, body: UserTypingPubMsgBody) extends StandardMsg
case class UserTypingPubMsgBody(chatId: String) case class UserTypingPubMsgBody(chatId: String)

View File

@ -0,0 +1,32 @@
import { RedisMessage } from '../types';
import {throwErrorIfInvalidInput} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
throwErrorIfInvalidInput(input,
[
{name: 'chatId', type: 'string', required: true},
{name: 'messageId', type: 'string', required: true},
{name: 'reactionEmoji', type: 'string', required: true},
]
)
const eventName = `DeleteGroupChatMessageReactionReqMsg`;
const routing = {
meetingId: sessionVariables['x-hasura-meetingid'] as String,
userId: sessionVariables['x-hasura-userid'] as String
};
const header = {
name: eventName,
meetingId: routing.meetingId,
userId: routing.userId
};
const body = {
chatId: input.chatId,
messageId: input.messageId,
reactionEmoji: input.reactionEmoji,
};
return { eventName, routing, header, body };
}

View File

@ -0,0 +1,32 @@
import { RedisMessage } from '../types';
import {throwErrorIfInvalidInput} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
throwErrorIfInvalidInput(input,
[
{name: 'chatId', type: 'string', required: true},
{name: 'messageId', type: 'string', required: true},
{name: 'reactionEmoji', type: 'string', required: true},
]
)
const eventName = `SendGroupChatMessageReactionReqMsg`;
const routing = {
meetingId: sessionVariables['x-hasura-meetingid'] as String,
userId: sessionVariables['x-hasura-userid'] as String
};
const header = {
name: eventName,
meetingId: routing.meetingId,
userId: routing.userId
};
const body = {
chatId: input.chatId,
messageId: input.messageId,
reactionEmoji: input.reactionEmoji,
};
return { eventName, routing, header, body };
}

View File

@ -1135,6 +1135,21 @@ LEFT JOIN "chat_user" chat_with ON chat_with."meetingId" = cm."meetingId"
AND chat_with."userId" != cu."userId" AND chat_with."userId" != cu."userId"
WHERE cm."chatId" != 'MAIN-PUBLIC-GROUP-CHAT'; WHERE cm."chatId" != 'MAIN-PUBLIC-GROUP-CHAT';
CREATE TABLE "chat_message_reaction" (
"meetingId" varchar(100),
"messageId" varchar(100) REFERENCES "chat_message"("messageId") ON DELETE CASCADE,
"userId" varchar(100) not null,
"reactionEmoji" varchar(25),
"createdAt" timestamp with time zone,
CONSTRAINT chat_message_reaction_pk PRIMARY KEY ("messageId", "userId", "reactionEmoji"),
FOREIGN KEY ("meetingId", "userId") REFERENCES "user"("meetingId","userId") ON DELETE CASCADE
);
CREATE INDEX "chat_message_reaction_meeting_message_idx" ON "chat_message_reaction"("meetingId","messageId");
CREATE INDEX "chat_message_reaction_meeting_message_idx_rev" ON "chat_message_reaction"("messageId", "meetingId");
CREATE OR REPLACE VIEW "v_chat_message_reaction" AS SELECT * FROM "chat_message_reaction";
--============ Presentation / Annotation --============ Presentation / Annotation

View File

@ -109,6 +109,14 @@ type Mutation {
): Boolean ): Boolean
} }
type Mutation {
chatDeleteMessageReaction(
chatId: String!
messageId: String!
reactionEmoji: String!
): Boolean
}
type Mutation { type Mutation {
chatEditMessage( chatEditMessage(
chatId: String! chatId: String!
@ -136,6 +144,14 @@ type Mutation {
): Boolean ): Boolean
} }
type Mutation {
chatSendMessageReaction(
chatId: String!
messageId: String!
reactionEmoji: String!
): Boolean
}
type Mutation { type Mutation {
chatSetLastSeen: Boolean chatSetLastSeen: Boolean
} }

View File

@ -101,6 +101,12 @@ actions:
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions: permissions:
- role: bbb_client - role: bbb_client
- name: chatDeleteMessageReaction
definition:
kind: synchronous
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions:
- role: bbb_client
- name: chatEditMessage - name: chatEditMessage
definition: definition:
kind: synchronous kind: synchronous
@ -125,6 +131,12 @@ actions:
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions: permissions:
- role: bbb_client - role: bbb_client
- name: chatSendMessageReaction
definition:
kind: synchronous
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions:
- role: bbb_client
- name: chatSetLastSeen - name: chatSetLastSeen
definition: definition:
kind: synchronous kind: synchronous

View File

@ -36,6 +36,17 @@ object_relationships:
remote_table: remote_table:
name: v_user_ref name: v_user_ref
schema: public schema: public
array_relationships:
- name: reactions
using:
manual_configuration:
column_mapping:
meetingId: meetingId
messageId: messageId
insertion_order: null
remote_table:
name: v_chat_message_reaction
schema: public
select_permissions: select_permissions:
- role: bbb_client - role: bbb_client
permission: permission:

View File

@ -36,6 +36,17 @@ object_relationships:
remote_table: remote_table:
name: v_user_ref name: v_user_ref
schema: public schema: public
array_relationships:
- name: reactions
using:
manual_configuration:
column_mapping:
meetingId: meetingId
messageId: messageId
insertion_order: null
remote_table:
name: v_chat_message_reaction
schema: public
select_permissions: select_permissions:
- role: bbb_client - role: bbb_client
permission: permission:

View File

@ -0,0 +1,33 @@
table:
name: v_chat_message_reaction
schema: public
configuration:
column_config: {}
custom_column_names: {}
custom_name: chat_message_reaction
custom_root_fields: {}
object_relationships:
- name: user
using:
manual_configuration:
column_mapping:
meetingId: meetingId
userId: userId
insertion_order: null
remote_table:
name: v_user_ref
schema: public
select_permissions:
- role: bbb_client
permission:
columns:
- createdAt
- reactionEmoji
- userId
filter:
meetingId:
_eq: X-Hasura-MeetingId
allow_aggregations: true
query_root_fields: []
subscription_root_fields: []
comment: ""

View File

@ -7,6 +7,7 @@
- "!include public_v_chat.yaml" - "!include public_v_chat.yaml"
- "!include public_v_chat_message_private.yaml" - "!include public_v_chat_message_private.yaml"
- "!include public_v_chat_message_public.yaml" - "!include public_v_chat_message_public.yaml"
- "!include public_v_chat_message_reaction.yaml"
- "!include public_v_chat_user.yaml" - "!include public_v_chat_user.yaml"
- "!include public_v_current_time.yaml" - "!include public_v_current_time.yaml"
- "!include public_v_externalVideo.yaml" - "!include public_v_externalVideo.yaml"