Social: Refactor social group and topics management functionalities

pull/5198/head
christianbeeznst 9 months ago
parent 9d2212ae2e
commit 90b6200f96
  1. 50
      assets/css/scss/_social.scss
  2. 1
      assets/vue/components/basecomponents/BaseEditor.vue
  3. 68
      assets/vue/components/basecomponents/BaseFileUploadMultiple.vue
  4. 1
      assets/vue/components/basecomponents/ChamiloIcons.js
  5. 157
      assets/vue/components/usergroup/GroupDiscussionTopics.vue
  6. 112
      assets/vue/components/usergroup/GroupDiscussions.vue
  7. 66
      assets/vue/components/usergroup/MessageItem.vue
  8. 6
      assets/vue/router/usergroup.js
  9. 81
      assets/vue/services/socialService.js
  10. 18
      assets/vue/views/usergroup/Show.vue
  11. 89
      assets/vue/views/userreluser/Invitations.vue
  12. 13
      public/main/inc/lib/message.lib.php
  13. 152
      src/CoreBundle/Controller/SocialController.php
  14. 2
      src/CoreBundle/DataProvider/MessageByGroupDataProvider.php
  15. 2
      src/CoreBundle/Entity/Message.php
  16. 106
      src/CoreBundle/Repository/MessageRepository.php
  17. 4
      src/CoreBundle/Repository/Node/UsergroupRepository.php

@ -478,8 +478,8 @@
.author-avatar img, .author-avatar .mdi { .author-avatar img, .author-avatar .mdi {
border-radius: 50%; border-radius: 50%;
width: 50px; width: 100%;
height: 50px; height: 40px;
} }
.author-avatar { .author-avatar {
@ -983,6 +983,52 @@
} }
} }
.social-group-messages {
.message-item {
display: flex;
border-bottom: 1px solid #ccc;
padding: 10px;
align-items: flex-start;
}
.message-avatar {
margin-right: 15px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.message-body {
flex: 1;
}
.message-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.message-author {
font-weight: bold;
}
.message-actions {
display: flex;
justify-content: flex-end;
}
.message-actions button {
margin-left: 5px;
}
.child-messages {
margin-left: 20px;
}
}
.circle-green { .circle-green {
color: green; color: green;
} }

@ -35,7 +35,6 @@ const defaultEditorConfig = {
relative_urls: false, relative_urls: false,
height: 280, height: 280,
toolbar_mode: 'sliding', toolbar_mode: 'sliding',
file_picker_callback: browser,
autosave_ask_before_unload: true, autosave_ask_before_unload: true,
plugins: [ plugins: [
'fullpage advlist autolink lists link image charmap print preview anchor', 'fullpage advlist autolink lists link image charmap print preview anchor',

@ -0,0 +1,68 @@
<template>
<div class="flex items-center gap-2">
<BaseButton
:label="label"
:size="size"
type="primary"
icon="attachment"
@click="showFileDialog"
/>
<div v-if="files.length > 0">
<p class="text-gray-500" v-for="file in files" :key="file.name">
{{ file.name }}
</p>
</div>
<input
ref="inputFile"
type="file"
class="hidden"
:accept="acceptFileType"
@change="filesSelected"
multiple
/>
</div>
</template>
<script setup>
import BaseButton from "./BaseButton.vue"
import { computed, ref } from "vue"
const props = defineProps({
modelValue: Array,
label: String,
accept: {
type: String,
default: "",
},
size: {
type: String,
default: "normal",
},
})
const emit = defineEmits(["update:modelValue"])
const inputFile = ref(null)
const acceptFileType = computed(() => {
if (props.accept === "image") {
return "image/*"
}
return props.accept
})
const files = computed({
get: () => props.modelValue,
set: (newValue) => {
emit("update:modelValue", newValue)
},
})
const filesSelected = () => {
files.value = Array.from(inputFile.value.files)
}
const showFileDialog = () => {
inputFile.value.click()
}
</script>

@ -117,4 +117,5 @@ export const chamiloIconToClass = {
"template-not-selected": "mdi mdi-file-outline", "template-not-selected": "mdi mdi-file-outline",
"map-search": "mdi mdi-map-search-outline", "map-search": "mdi mdi-map-search-outline",
"join-group": "mdi mdi-account-multiple-plus", "join-group": "mdi mdi-account-multiple-plus",
"add-topic": "mdi mdi-forum-outline",
}; };

@ -0,0 +1,157 @@
<template>
<div class="social-group-show group-info text-center">
<div class="group-header">
<h1 class="group-title">{{ groupInfo?.title || '...' }}</h1>
<p class="group-description">{{ groupInfo?.description }}</p>
</div>
</div>
<div v-if="!isLoading" class="discussion">
<BaseButton @click="goBack" class="back-button mb-8" icon="back" type="button" :label="t('Back to the list')" />
<h2>{{ firstMessageTitle || 'Discussion Thread' }}</h2>
<div class="message-list mt-8">
<MessageItem
v-for="message in messages"
:key="message.id"
:message="message"
:currentUser="user"
:indentation="0"
:isMainMessage="message.parentId === null || message.parentId === 0"
:isModerator="groupInfo.isModerator"
@replyMessage="openDialogForReply"
@editMessage="openDialogForEdit"
@deleteMessage="deleteMessage"
/>
</div>
</div>
<Dialog header="Reply/Edit Message" v-model:visible="showMessageDialog" modal closable>
<form @submit.prevent="handleSubmit">
<BaseInputText v-if="isEditMode" id="title" :label="t('Title')" v-model="messageTitle" :isInvalid="titleError" />
<BaseEditor editorId="messageEditor" v-model="messageContent" title="Message" />
<BaseFileUploadMultiple v-model="files" :label="t('Add files')" accept="image/png, image/jpeg" />
<BaseButton type="button" :label="t('Send message')" icon="save" @click="handleSubmit" class="mt-8" />
</form>
</Dialog>
</template>
<script setup>
import { onMounted, reactive, computed, ref, toRefs } from "vue"
import { useRouter, useRoute } from "vue-router"
import { useSocialInfo } from "../../composables/useSocialInfo"
import axios from "axios"
import MessageItem from "./MessageItem.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import { useI18n } from "vue-i18n"
import BaseInputText from "../basecomponents/BaseInputText.vue"
import BaseEditor from "../basecomponents/BaseEditor.vue"
import BaseFileUploadMultiple from "../basecomponents/BaseFileUploadMultiple.vue"
const router = useRouter()
const route = useRoute()
const { user, groupInfo, isGroup, loadGroup, isLoading } = useSocialInfo()
const messages = ref([])
const { t } = useI18n()
const firstMessageTitle = computed(() => {
return messages.value.length > 0 ? messages.value[0].title : null
})
const showMessageDialog = ref(false)
const isEditMode = ref(false)
const currentMessageId = ref(null)
const state = reactive({
messageTitle: '',
messageContent: '',
files: [],
titleError: false,
})
const { messageTitle, messageContent, files, titleError } = toRefs(state)
const getIndentation = (message) => {
let indent = 0
let parent = messages.value.find(m => m.id === message.parentId)
while (parent) {
indent += 30
parent = messages.value.find(m => m.id === parent.parentId)
}
return `${indent}px`
}
const fetchMessages = async () => {
const groupId = route.params.group_id
const discussionId = route.params.discussion_id
try {
const response = await axios.get(`/social-network/group/${groupId}/discussion/${discussionId}/messages`)
messages.value = response.data
} catch (error) {
console.error('Error fetching messages:', error)
}
}
function openDialogForReply(message) {
isEditMode.value = false
currentMessageId.value = message.id
showMessageDialog.value = true
}
function openDialogForEdit(message) {
isEditMode.value = true
currentMessageId.value = message.id
messageTitle.value = message.title
messageContent.value = message.content
showMessageDialog.value = true
}
async function handleSubmit() {
if (isEditMode.value && title.value.trim() === '') {
titleError.value = true
return
}
const filesArray = files.value
const formData = new FormData()
formData.append('action', isEditMode.value ? 'edit_message_group' : 'reply_message_group')
formData.append('title', messageTitle.value)
formData.append('content', messageContent.value)
if (isEditMode.value) {
formData.append('messageId', currentMessageId.value)
} else {
formData.append('parentId', currentMessageId.value)
}
formData.append('userId', user.value.id)
formData.append('groupId', groupInfo.value.id)
for (let i = 0; i < filesArray.length; i++) {
formData.append('files[]', filesArray[i])
}
try {
await axios.post('/social-network/group-action', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
showMessageDialog.value = false
await fetchMessages()
} catch (error) {
console.error('Error submitting the form:', error)
}
}
const deleteMessage = async (message) => {
try {
const confirmed = confirm(`Are you sure you want to delete this message: ${message.title}?`)
if (!confirmed) {
return
}
const data = {
action: 'delete_message_group',
messageId: message.id,
userId: user.value.id,
groupId: groupInfo.value.id
}
await axios.post('/social-network/group-action', data)
await router.push({ name: 'UserGroupShow', params: { group_id: groupInfo.value.id } })
} catch (error) {
console.error('Error deleting the message:', error)
}
}
onMounted(() => {
fetchMessages()
})
const goBack = () => {
router.back()
}
</script>

@ -1,39 +1,57 @@
<template> <template>
<div> <div>
<div class="discussions-header"> <div class="discussions-header relative">
<h2>Discussions</h2> <BaseButton
<a :href="threadCreationUrl" class="btn btn-primary create-thread-btn ajax"> :label="t('Create thread')"
<i class="pi pi-plus"></i> {{ t("Create thread") }} icon="add-topic"
</a> class="create-thread-btn absolute right-0"
</div> @click="showCreateThreadDialog = true"
<div class="discussion-item" v-for="discussion in discussions" :key="discussion.id"> type="button"
<div class="discussion-content"> />
<div class="discussion-title" v-html="discussion.title"></div>
<div class="discussion-details">
<i class="mdi mdi-message-reply-text icon"></i>
<span>{{ discussion.repliesCount }} {{ t("Replies") }}</span>
<i class="mdi mdi-clock-outline icon"></i>
<span>{{ t("Created") }} {{ relativeDatetime(discussion.sendDate) }}</span>
</div>
</div> </div>
<div class="discussion-author"> <div v-for="(discussion, index) in discussions" :key="discussion.id">
<div class="author-avatar"> <div class="discussion-item" @click="selectDiscussion(discussion)">
<img v-if="discussion.sender.illustrationUrl" :src="discussion.sender.illustrationUrl" alt="Author avatar"> <div class="discussion-content">
<i v-else class="mdi mdi-account-circle-outline"></i> <div class="discussion-title" v-html="discussion.title"></div>
<div class="discussion-details">
<i class="mdi mdi-message-reply-text icon"></i>
<span>{{ discussion.repliesCount }} {{ t("Replies") }}</span>
<i class="mdi mdi-clock-outline icon"></i>
<span>{{ t("Created") }} {{ relativeDatetime(discussion.sendDate) }}</span>
</div>
</div>
<div class="discussion-author">
<div class="author-avatar">
<img v-if="discussion.sender.illustrationUrl" :src="discussion.sender.illustrationUrl" alt="Author avatar">
<i v-else class="mdi mdi-account-circle-outline"></i>
</div>
<div class="author-name mt-4">{{ discussion.sender.username }}</div>
</div>
</div> </div>
<div class="author-name mt-4">{{ discussion.sender.username }}</div>
</div>
</div> </div>
</div> </div>
<Dialog header="Create Thread" v-model:visible="showCreateThreadDialog" modal closable>
<form @submit.prevent="handleSubmit">
<BaseInputText id="title" label="Title" v-model="title" :isInvalid="titleError" />
<BaseEditor editorId="messageEditor" v-model="message" title="Message" />
<BaseFileUploadMultiple v-model="files" label="Add files" accept="image/png, image/jpeg" />
<BaseButton type="button" label="Send message" icon="save" @click="handleSubmit" class="mt-8" />
</form>
</Dialog>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, toRefs, reactive } from "vue"
import { useRoute } from 'vue-router' import { useRoute, useRouter } from "vue-router"
import axios from 'axios' import axios from 'axios'
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { useFormatDate } from "../../composables/formatDate" import { useFormatDate } from "../../composables/formatDate"
import { useSocialInfo } from "../../composables/useSocialInfo" import { useSocialInfo } from "../../composables/useSocialInfo"
import BaseButton from "../basecomponents/BaseButton.vue"
import BaseInputText from "../basecomponents/BaseInputText.vue"
import BaseEditor from "../basecomponents/BaseEditor.vue"
import BaseFileUploadMultiple from "../basecomponents/BaseFileUploadMultiple.vue"
const route = useRoute() const route = useRoute()
const discussions = ref([]) const discussions = ref([])
@ -41,6 +59,51 @@ const groupId = ref(route.params.group_id)
const { t } = useI18n() const { t } = useI18n()
const { relativeDatetime } = useFormatDate() const { relativeDatetime } = useFormatDate()
const { user, groupInfo, isGroup, loadGroup, isLoading } = useSocialInfo() const { user, groupInfo, isGroup, loadGroup, isLoading } = useSocialInfo()
const router = useRouter()
function selectDiscussion(discussion) {
router.push({
name: 'UserGroupDiscussions',
params: {
group_id: groupId.value,
discussion_id: discussion.id
}
})
}
const state = reactive({
showCreateThreadDialog: false,
title: '',
message: '',
files: [],
titleError: false,
})
const { showCreateThreadDialog, title, message, files, titleError } = toRefs(state)
async function handleSubmit() {
if (title.value.trim() === '') {
titleError.value = true
return
}
const filesArray = files.value
const formData = new FormData()
formData.append('action', 'add_message_group')
formData.append('title', title.value)
formData.append('content', message.value)
formData.append('userId', user.value.id)
formData.append('groupId', groupId.value)
for (let i = 0; i < filesArray.length; i++) {
formData.append('files[]', filesArray[i])
}
try {
const response = await axios.post('/social-network/group-action', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
} catch (error) {
console.error('Error when making request', error)
}
}
onMounted(async () => { onMounted(async () => {
if (groupId.value) { if (groupId.value) {
try { try {
@ -55,7 +118,4 @@ onMounted(async () => {
} }
} }
}) })
const threadCreationUrl = computed(() => {
return `/main/social/message_for_group_form.inc.php?view_panel=1&user_friend=1&group_id=${groupId.value}&action=add_message_group`
})
</script> </script>

@ -0,0 +1,66 @@
<template>
<div class="message-item social-group-messages" :style="{ paddingLeft: indentation + 'px' }">
<div class="message-avatar">
<img :src="message.avatar" alt="Avatar" class="avatar">
</div>
<div class="message-body">
<div class="message-meta">
<span class="message-author">{{ message.user }}</span>
<span class="message-date">{{ relativeDatetime(message.created) }}</span>
</div>
<div class="message-content" v-html="message.content"></div>
<div class="message-attachments mt-8" v-if="message.attachment && message.attachment.length">
<div v-for="(attachment, index) in message.attachment" :key="index" class="attachment-link">
<a :href="attachment.link" target="_blank">{{ attachment.filename }}</a>
<span> ({{ formatSize(attachment.size) }})</span>
</div>
</div>
<div class="message-actions">
<BaseIcon icon="reply" size="normal" @click="$emit('replyMessage', message)" />
<div>
<BaseIcon icon="edit" v-if="isMessageCreator(message)" size="normal" @click="$emit('editMessage', message)" />
<BaseIcon icon="delete" size="normal" v-if="isMainMessage && isModerator" @click="$emit('deleteMessage', message)" />
</div>
</div>
<div class="child-messages">
<MessageItem
v-for="child in message.children"
:key="child.id"
:message="child"
:currentUser="currentUser"
:indentation="indentation + 20"
@replyMessage="$emit('replyMessage', $event)"
@editMessage="$emit('editMessage', $event)"
@deleteMessage="$emit('deleteMessage', $event)"
/>
</div>
</div>
</div>
</template>
<script setup>
import BaseIcon from "../basecomponents/BaseIcon.vue"
import { useFormatDate } from "../../composables/formatDate"
const { relativeDatetime } = useFormatDate()
const { message, indentation, currentUser, isMainMessage, isModerator } = defineProps({
message: Object,
indentation: {
type: Number,
default: 0,
},
currentUser: Object,
isMainMessage: Boolean,
isModerator: Boolean
})
const formatSize = (size) => {
if (size < 1024) return size + ' B'
let i = Math.floor(Math.log(size) / Math.log(1024))
let num = (size / Math.pow(1024, i)).toFixed(2)
let unit = ['B', 'KB', 'MB', 'GB', 'TB'][i]
return `${num} ${unit}`
}
const isMessageCreator = (message) => {
return message.senderId === currentUser.id
}
</script>

@ -27,6 +27,12 @@ export default {
path: 'invite/:group_id?', path: 'invite/:group_id?',
component: () => import('../views/usergroup/Invite.vue'), component: () => import('../views/usergroup/Invite.vue'),
props: true props: true
},
{
name: 'UserGroupDiscussions',
path: 'show/:group_id/discussions/:discussion_id',
component: () => import('../components/usergroup/GroupDiscussionTopics.vue'),
props: true
} }
] ]
}; };

@ -58,4 +58,85 @@ export default {
throw error; throw error;
} }
}, },
async fetchInvitations(userId) {
try {
const response = await axios.get(`${API_URL}/invitations/${userId}`);
return response.data;
} catch (error) {
console.error('Error fetching invitations:', error);
throw error;
}
},
async acceptInvitation(userId, targetUserId) {
try {
const response = await axios.post(`${API_URL}/user-action`, {
userId,
targetUserId,
action: 'add_friend',
is_my_friend: true,
});
return response.data;
} catch (error) {
console.error('Error accepting invitation:', error);
throw error;
}
},
async denyInvitation(userId, targetUserId) {
try {
const response = await axios.post(`${API_URL}/user-action`, {
userId,
targetUserId,
action: 'deny_friend',
});
return response.data;
} catch (error) {
console.error('Error denying invitation:', error);
throw error;
}
},
async acceptGroupInvitation(userId, groupId) {
try {
const response = await axios.post(`${API_URL}/group-action`, {
userId,
groupId,
action: 'accept',
});
return response.data;
} catch (error) {
console.error('Error accepting group invitation:', error);
throw error;
}
},
async denyGroupInvitation(userId, groupId) {
try {
const response = await axios.post(`${API_URL}/group-action`, {
userId,
groupId,
action: 'deny',
});
return response.data;
} catch (error) {
console.error('Error denying group invitation:', error);
throw error;
}
},
async joinGroup(userId, groupId) {
try {
const response = await axios.post(`${API_URL}/group-action`, {
userId,
groupId,
action: 'join',
});
return response.data;
} catch (error) {
console.error('Error joining the group:', error);
throw error;
}
},
}; };

@ -75,27 +75,25 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from "vue-router"
import GroupDiscussions from "../../components/usergroup/GroupDiscussions.vue" import GroupDiscussions from "../../components/usergroup/GroupDiscussions.vue"
import GroupMembers from "../../components/usergroup/GroupMembers.vue" import GroupMembers from "../../components/usergroup/GroupMembers.vue"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { useSocialInfo } from "../../composables/useSocialInfo" import { useSocialInfo } from "../../composables/useSocialInfo"
import axios from "axios"
import BaseButton from "../../components/basecomponents/BaseButton.vue" import BaseButton from "../../components/basecomponents/BaseButton.vue"
import socialService from "../../services/socialService"
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter()
const activeTab = ref('discussions') const activeTab = ref('discussions')
const { user, groupInfo, isGroup, loadGroup, isLoading } = useSocialInfo() const { user, groupInfo, isGroup, loadGroup, isLoading } = useSocialInfo()
const joinGroup = async () => {
const joinGroup = async delta => {
try { try {
const response = await axios.post('/social-network/group-action', { const response = await socialService.joinGroup(user.value.id, groupInfo.value.id)
userId: user.value.id, if (response.success) {
groupId: groupInfo.value.id, router.go(delta)
action: 'join'
})
if (response.data.success) {
await loadGroup(groupInfo.value.id)
} }
} catch (error) { } catch (error) {
console.error('Error joining the group:', error) console.error('Error joining the group:', error)

@ -32,6 +32,7 @@ import { inject, onMounted, ref, watchEffect } from "vue"
import InvitationList from "../../components/userreluser/InvitationList.vue" import InvitationList from "../../components/userreluser/InvitationList.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue" import BaseButton from "../../components/basecomponents/BaseButton.vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import socialService from "../../services/socialService"
const receivedInvitations = ref([]) const receivedInvitations = ref([])
const sentInvitations = ref([]) const sentInvitations = ref([])
@ -47,97 +48,61 @@ watchEffect(() => {
fetchInvitations(user.value.id) fetchInvitations(user.value.id)
} }
}) })
function goToSearch() {
router.push({ name: 'SocialSearch' })
}
const fetchInvitations = async (userId) => { const fetchInvitations = async (userId) => {
if (!userId) return if (!userId) return
try { try {
const response = await axios.get(`/social-network/invitations/${userId}`) const data = await socialService.fetchInvitations(userId)
console.log('Invitations :::', response.data) console.log('Invitations :::', data)
receivedInvitations.value = response.data.receivedInvitations receivedInvitations.value = data.receivedInvitations
sentInvitations.value = response.data.sentInvitations sentInvitations.value = data.sentInvitations
pendingInvitations.value = response.data.pendingGroupInvitations pendingInvitations.value = data.pendingGroupInvitations
} catch (error) { } catch (error) {
console.error('Error fetching invitations:', error) console.error('Error fetching invitations:', error)
} }
} }
function goToSearch() {
router.push({ name: 'SocialSearch' })
}
const acceptInvitation = async (invitationId) => { const acceptInvitation = async (invitationId) => {
const invitation = receivedInvitations.value.find(invite => invite.id === invitationId)
if (!invitation) return
console.log('Invitation object:', invitation)
const data = {
userId: user.value.id,
targetUserId: invitation.itemId,
action: 'add_friend',
is_my_friend: true,
}
try { try {
const response = await axios.post('/social-network/user-action', data) await socialService.acceptInvitation(user.value.id, invitationId)
if (response.data.success) { console.log('Invitation accepted successfully')
console.log('Invitation accepted successfully') await fetchInvitations(user.value.id)
await fetchInvitations(user.value.id)
} else {
console.error('Failed to accept invitation')
}
} catch (error) { } catch (error) {
console.error('Error accepting invitation:', error) console.error('Error accepting invitation:', error)
} }
} }
const denyInvitation = async (invitationId) => { const denyInvitation = async (invitationId) => {
const invitation = receivedInvitations.value.find(invite => invite.id === invitationId)
if (!invitation) return
const data = {
userId: user.value.id,
targetUserId: invitation.itemId,
action: 'deny_friend',
}
try { try {
const response = await axios.post('/social-network/user-action', data) await socialService.denyInvitation(user.value.id, invitationId)
if (response.data.success) { console.log('Invitation denied successfully')
console.log('Invitation denied successfully') await fetchInvitations(user.value.id)
await fetchInvitations(user.value.id)
} else {
console.error('Failed to deny invitation')
}
} catch (error) { } catch (error) {
console.error('Error denying invitation:', error) console.error('Error denying invitation:', error)
} }
} }
const acceptGroupInvitation = async (groupId) => { const acceptGroupInvitation = async (groupId) => {
try { try {
const response = await axios.post('/social-network/group-action', { await socialService.acceptGroupInvitation(user.value.id, groupId)
userId: user.value.id, console.log('Group invitation accepted successfully')
groupId: groupId, await fetchInvitations(user.value.id)
action: 'accept',
});
if (response.data.success) {
console.log('Group invitation accepted successfully');
await fetchInvitations(user.value.id)
} else {
console.error('Failed to accept group invitation');
}
} catch (error) { } catch (error) {
console.error('Error accepting group invitation:', error); console.error('Error accepting group invitation:', error)
} }
} }
const denyGroupInvitation = async (groupId) => { const denyGroupInvitation = async (groupId) => {
try { try {
const response = await axios.post('/social-network/group-action', { await socialService.denyGroupInvitation(user.value.id, groupId)
userId: user.value.id, console.log('Group invitation denied successfully')
groupId: groupId, await fetchInvitations(user.value.id)
action: 'deny',
});
if (response.data.success) {
console.log('Group invitation denied successfully');
await fetchInvitations(user.value.id)
} else {
console.error('Failed to deny group invitation');
}
} catch (error) { } catch (error) {
console.error('Error denying group invitation:', error); console.error('Error denying group invitation:', error)
} }
} }
</script> </script>

@ -131,6 +131,7 @@ class MessageManager
$forwardId = 0, $forwardId = 0,
$checkCurrentAudioId = false, $checkCurrentAudioId = false,
$forceTitleWhenSendingEmail = false, $forceTitleWhenSendingEmail = false,
$msgType = null
) { ) {
$group_id = (int) $group_id; $group_id = (int) $group_id;
$receiverUserId = (int) $receiverUserId; $receiverUserId = (int) $receiverUserId;
@ -276,6 +277,7 @@ class MessageManager
if (!empty($editMessageId)) { if (!empty($editMessageId)) {
$message = $repo->find($editMessageId); $message = $repo->find($editMessageId);
if (null !== $message) { if (null !== $message) {
$message->setTitle($subject);
$message->setContent($content); $message->setContent($content);
$em->persist($message); $em->persist($message);
$em->flush(); $em->flush();
@ -292,6 +294,11 @@ class MessageManager
->setGroup($group) ->setGroup($group)
->setParent($parent) ->setParent($parent)
; ;
if (isset($msgType)) {
$message->setMsgType($msgType);
}
$em->persist($message); $em->persist($message);
$em->flush(); $em->flush();
$messageId = $message->getId(); $messageId = $message->getId();
@ -518,12 +525,14 @@ class MessageManager
// Search for files inside the $_FILES, when uploading several files from the form. // Search for files inside the $_FILES, when uploading several files from the form.
if ($request->files->count()) { if ($request->files->count()) {
$allFiles = $request->files->all();
$filesArray = array_key_exists('files', $allFiles) ? $allFiles['files'] : $allFiles;
/** @var UploadedFile|null $fileRequest */ /** @var UploadedFile|null $fileRequest */
foreach ($request->files->all() as $fileRequest) { foreach ($filesArray as $fileRequest) {
if (null === $fileRequest) { if (null === $fileRequest) {
continue; continue;
} }
if ($fileRequest->getClientOriginalName() === $file['name']) { if ($fileRequest instanceof UploadedFile && $fileRequest->getClientOriginalName() === $file['name']) {
$fileToUpload = $fileRequest; $fileToUpload = $fileRequest;
break; break;
} }

@ -8,6 +8,8 @@ namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\ExtraField; use Chamilo\CoreBundle\Entity\ExtraField;
use Chamilo\CoreBundle\Entity\Legal; use Chamilo\CoreBundle\Entity\Legal;
use Chamilo\CoreBundle\Entity\Message;
use Chamilo\CoreBundle\Entity\MessageAttachment;
use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Entity\Usergroup; use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Entity\UserRelUser; use Chamilo\CoreBundle\Entity\UserRelUser;
@ -17,6 +19,7 @@ use Chamilo\CoreBundle\Repository\LanguageRepository;
use Chamilo\CoreBundle\Repository\LegalRepository; use Chamilo\CoreBundle\Repository\LegalRepository;
use Chamilo\CoreBundle\Repository\MessageRepository; use Chamilo\CoreBundle\Repository\MessageRepository;
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository; use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
use Chamilo\CoreBundle\Repository\Node\MessageAttachmentRepository;
use Chamilo\CoreBundle\Repository\Node\UsergroupRepository; use Chamilo\CoreBundle\Repository\Node\UsergroupRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository; use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\Repository\TrackEOnlineRepository; use Chamilo\CoreBundle\Repository\TrackEOnlineRepository;
@ -24,6 +27,7 @@ use Chamilo\CoreBundle\Serializer\UserToJsonNormalizer;
use Chamilo\CoreBundle\Settings\SettingsManager; use Chamilo\CoreBundle\Settings\SettingsManager;
use Chamilo\CourseBundle\Repository\CForumThreadRepository; use Chamilo\CourseBundle\Repository\CForumThreadRepository;
use DateTime; use DateTime;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
use ExtraFieldValue; use ExtraFieldValue;
@ -327,6 +331,21 @@ class SocialController extends AbstractController
return $this->json(['groups' => $groupsArray]); return $this->json(['groups' => $groupsArray]);
} }
#[Route('/group/{groupId}/discussion/{discussionId}/messages', name: 'chamilo_core_social_group_discussion_messages')]
public function getDiscussionMessages(
$groupId,
$discussionId,
MessageRepository $messageRepository,
UserRepository $userRepository,
MessageAttachmentRepository $attachmentRepository
): JsonResponse {
$messages = $messageRepository->getMessagesByGroupAndMessage((int) $groupId, (int) $discussionId);
$formattedMessages = $this->formatMessagesHierarchy($messages, $userRepository, $attachmentRepository);
return $this->json($formattedMessages);
}
#[Route('/get-forum-link', name: 'get_forum_link')] #[Route('/get-forum-link', name: 'get_forum_link')]
public function getForumLink( public function getForumLink(
SettingsManager $settingsManager, SettingsManager $settingsManager,
@ -695,13 +714,41 @@ class SocialController extends AbstractController
} }
#[Route('/group-action', name: 'chamilo_core_social_group_action')] #[Route('/group-action', name: 'chamilo_core_social_group_action')]
public function groupAction(Request $request, UsergroupRepository $usergroupRepository, EntityManagerInterface $em): JsonResponse public function groupAction(
{ Request $request,
$data = json_decode($request->getContent(), true); UsergroupRepository $usergroupRepository,
EntityManagerInterface $em,
$userId = $data['userId'] ?? null; MessageRepository $messageRepository
$groupId = $data['groupId'] ?? null; ): JsonResponse {
$action = $data['action'] ?? null; if (0 === strpos($request->headers->get('Content-Type'), 'multipart/form-data')) {
$userId = $request->request->get('userId');
$groupId = $request->request->get('groupId');
$action = $request->request->get('action');
$title = $request->request->get('title', '');
$content = $request->request->get('content', '');
$parentId = $request->request->get('parentId', 0);
$editMessageId = $request->request->get('messageId', 0);
$structuredFiles = [];
if ($request->files->has('files')) {
$files = $request->files->get('files');
foreach ($files as $file) {
$structuredFiles[] = [
'name' => $file->getClientOriginalName(),
'full_path' => $file->getRealPath(),
'type' => $file->getMimeType(),
'tmp_name' => $file->getPathname(),
'error' => $file->getError(),
'size' => $file->getSize(),
];
}
}
} else {
$data = json_decode($request->getContent(), true);
$userId = $data['userId'] ?? null;
$groupId = $data['groupId'] ?? null;
$action = $data['action'] ?? null;
}
if (!$userId || !$groupId || !$action) { if (!$userId || !$groupId || !$action) {
return $this->json(['error' => 'Missing parameters'], Response::HTTP_BAD_REQUEST); return $this->json(['error' => 'Missing parameters'], Response::HTTP_BAD_REQUEST);
@ -722,18 +769,51 @@ class SocialController extends AbstractController
} }
break; break;
case 'join': case 'join':
$usergroupRepository->addUserToGroup($userId, $groupId); $usergroupRepository->addUserToGroup($userId, $groupId);
break; break;
case 'deny': case 'deny':
$usergroupRepository->removeUserFromGroup($userId, $groupId, false);
break;
case 'leave': case 'leave':
$usergroupRepository->removeUserFromGroup($userId, $groupId); $usergroupRepository->removeUserFromGroup($userId, $groupId);
break; break;
case 'reply_message_group':
$title = $title ?: substr(strip_tags($content), 0, 50);
case 'edit_message_group':
case 'add_message_group':
$res = MessageManager::send_message(
$userId,
$title,
$content,
$structuredFiles,
[],
$groupId,
$parentId,
$editMessageId,
0,
$userId,
false,
0,
false,
false,
Message::MESSAGE_TYPE_GROUP
);
break;
case 'delete_message_group':
$messageId = $data['messageId'] ?? null;
if (!$messageId) {
return $this->json(['error' => 'Missing messageId parameter'], Response::HTTP_BAD_REQUEST);
}
$messageRepository->deleteTopicAndChildren($groupId, $messageId);
break;
default: default:
return $this->json(['error' => 'Invalid action'], Response::HTTP_BAD_REQUEST); return $this->json(['error' => 'Invalid action'], Response::HTTP_BAD_REQUEST);
} }
@ -855,6 +935,60 @@ class SocialController extends AbstractController
return new JsonResponse(['success' => 'Group and image saved successfully'], Response::HTTP_OK); return new JsonResponse(['success' => 'Group and image saved successfully'], Response::HTTP_OK);
} }
/**
* Formats a hierarchical structure of messages for display.
*
* This function takes an array of Message entities and recursively formats them into a hierarchical structure.
* Each message is formatted with details such as user information, creation date, content, and attachments.
* The function also assigns a level to each message based on its depth in the hierarchy for display purposes.
*/
private function formatMessagesHierarchy(array $messages, UserRepository $userRepository, MessageAttachmentRepository $attachmentRepository, ?int $parentId = null, int $level = 0): array
{
$formattedMessages = [];
/* @var Message $message */
foreach ($messages as $message) {
if (($message->getParent() ? $message->getParent()->getId() : null) === $parentId) {
$attachments = $message->getAttachments();
$attachmentsUrls = [];
$attachmentSize = 0;
if ($attachments) {
/** @var MessageAttachment $attachment */
foreach ($attachments as $attachment) {
$attachmentsUrls[] = [
'link' => $attachmentRepository->getResourceFileDownloadUrl($attachment),
'filename' => $attachment->getFilename(),
'size' => $attachment->getSize(),
];
$attachmentSize += $attachment->getSize();
}
}
$formattedMessage = [
'id' => $message->getId(),
'user' => $message->getSender()->getFullName(),
'created' => $message->getSendDate()->format(DateTimeInterface::ATOM),
'title' => $message->getTitle(),
'content' => $message->getContent(),
'parentId' => $message->getParent() ? $message->getParent()->getId() : null,
'avatar' => $userRepository->getUserPicture($message->getSender()->getId()),
'senderId' => $message->getSender()->getId(),
'attachment' => $attachmentsUrls ?? null,
'attachmentSize' => $attachmentSize > 0 ? $attachmentSize : null,
'level' => $level,
];
$children = $this->formatMessagesHierarchy($messages, $userRepository, $attachmentRepository, $message->getId(), $level + 1);
if (!empty($children)) {
$formattedMessage['children'] = $children;
}
$formattedMessages[] = $formattedMessage;
}
}
return $formattedMessages;
}
/** /**
* Checks the relationship between the current user and another user. * Checks the relationship between the current user and another user.
* *

@ -34,6 +34,6 @@ final class MessageByGroupDataProvider implements ProviderInterface
return []; return [];
} }
return $this->messageRepository->findByGroupId((int) $groupId); return $this->messageRepository->getMessagesByGroup((int) $groupId, true);
} }
} }

@ -47,7 +47,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new GetCollection( new GetCollection(
uriTemplate: '/messages/by-group/list', uriTemplate: '/messages/by-group/list',
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
name: 'get_messages_by_group', name: 'get_messages_by_social_group',
provider: MessageByGroupDataProvider::class provider: MessageByGroupDataProvider::class
), ),
new Post(securityPostDenormalize: "is_granted('CREATE', object)"), new Post(securityPostDenormalize: "is_granted('CREATE', object)"),

@ -86,6 +86,28 @@ class MessageRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
public function getMessagesByGroup(int $groupId, bool $mainMessagesOnly = false): array
{
$qb = $this->createQueryBuilder('m');
$qb->where('m.group = :group')
->andWhere('m.msgType = :msgType')
->setParameter('group', $groupId)
->setParameter('msgType', Message::MESSAGE_TYPE_GROUP);
if ($mainMessagesOnly) {
$qb->andWhere($qb->expr()->orX(
$qb->expr()->isNull('m.parent'),
$qb->expr()->eq('m.parent', ':zeroParent')
))
->setParameter('zeroParent', 0);
}
$qb->orderBy('m.id', 'ASC');
return $qb->getQuery()->getResult();
}
public function findReceivedInvitationsByUser(User $user): array public function findReceivedInvitationsByUser(User $user): array
{ {
return $this->createQueryBuilder('m') return $this->createQueryBuilder('m')
@ -246,4 +268,88 @@ class MessageRepository extends ServiceEntityRepository
return false; return false;
} }
public function getMessagesByGroupAndMessage(int $groupId, int $messageId): array {
$qb = $this->createQueryBuilder('m')
->where('m.group = :groupId')
->andWhere('m.msgType = :msgType')
->setParameter('groupId', $groupId)
->setParameter('msgType', Message::MESSAGE_TYPE_GROUP)
->orderBy('m.id', 'ASC');
$allMessages = $qb->getQuery()->getResult();
return $this->filterMessagesStartingFromId($allMessages, $messageId);
}
public function deleteTopicAndChildren(int $groupId, int $topicId): void
{
$entityManager = $this->getEntityManager();
$messages = $this->createQueryBuilder('m')
->where('m.group = :groupId AND (m.id = :topicId OR m.parent = :topicId)')
->setParameter('groupId', $groupId)
->setParameter('topicId', $topicId)
->getQuery()
->getResult();
/* @var Message $message */
foreach ($messages as $message) {
$message->setMsgType(Message::MESSAGE_STATUS_DELETED);
$entityManager->persist($message);
}
$entityManager->flush();
}
/**
* Filters messages starting from a specific message ID.
* This function first adds the message with the given start ID to the filtered list.
* Then, it checks all messages to find descendants of the message with the start ID
* and adds them to the filtered list as well.
*/
private function filterMessagesStartingFromId(array $messages, int $startId): array
{
$filtered = [];
foreach ($messages as $message) {
if ($message->getId() == $startId) {
$filtered[] = $message;
break;
}
}
foreach ($messages as $message) {
if ($this->isDescendantOf($message, $startId, $messages)) {
$filtered[] = $message;
}
}
return $filtered;
}
/**
* Determines if a given message is a descendant of another message identified by startId.
* A descendant is a message that has a chain of parent messages leading up to the message
* with the startId. This function iterates up the parent chain of the given message to
* check if any parent matches the startId.
*/
private function isDescendantOf(Message $message, int $startId, array $allMessages): bool
{
while ($parent = $message->getParent()) {
if ($parent->getId() == $startId) {
return true;
}
$filteredMessages = array_filter($allMessages, function ($m) use ($parent) {
return $m->getId() === $parent->getId();
});
$message = count($filteredMessages) ? array_values($filteredMessages)[0] : null;
if (!$message) {
break;
}
}
return false;
}
} }

@ -237,7 +237,7 @@ class UsergroupRepository extends ResourceRepository
$this->_em->flush(); $this->_em->flush();
} }
public function removeUserFromGroup(int $userId, int $groupId): bool public function removeUserFromGroup(int $userId, int $groupId, bool $checkLeaveRestriction = true): bool
{ {
/** @var Usergroup $group */ /** @var Usergroup $group */
$group = $this->find($groupId); $group = $this->find($groupId);
@ -247,7 +247,7 @@ class UsergroupRepository extends ResourceRepository
throw new Exception('Group or User not found'); throw new Exception('Group or User not found');
} }
if (!$group->getAllowMembersToLeaveGroup()) { if ($checkLeaveRestriction && !$group->getAllowMembersToLeaveGroup()) {
throw new Exception('Members are not allowed to leave this group'); throw new Exception('Members are not allowed to leave this group');
} }

Loading…
Cancel
Save