Merge pull request #4006 from AngelFQC/message-audio

Messages: Allow send recorded audio as attachment - BT#19044
pull/4009/head
Yannick Warnier 4 years ago committed by GitHub
commit 63cc3216de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 97
      assets/vue/components/AudioRecorder.vue
  2. 51
      assets/vue/components/message/Form.vue
  3. 8
      assets/vue/main.js
  4. 3
      assets/vue/services/messageattachment.js
  5. 88
      assets/vue/views/message/Create.vue
  6. 51
      src/CoreBundle/Controller/Api/CreateMessageAttachmentAction.php
  7. 2
      src/CoreBundle/Entity/Message.php
  8. 71
      src/CoreBundle/Entity/MessageAttachment.php
  9. 101
      tests/CoreBundle/Repository/MessageRepositoryTest.php

@ -1,26 +1,63 @@
<template>
<div class="py-2">
<q-btn v-if="!isRecording" color="primary" icon="mic" :label="$t('Start recording')" @click="record()"/>
<q-btn v-if="isRecording" color="red" icon="stop" :label="$t('Stop recording')" @click="stop()"/>
<div v-for="(audio, index) in audioList" :key="index" class="py-2">
<audio controls style="max-width: 100%;">
<source :src="audio">
<div class="py-4">
<q-btn
v-if="!isRecording"
:label="$t('Start recording')"
color="primary"
icon="mic"
@click="record()"
/>
<q-btn
v-if="isRecording"
:label="$t('Stop recording')"
color="red"
icon="stop"
@click="stop()"
/>
<div
v-for="(audio, index) in audioList"
:key="index"
class="py-2"
>
<audio
class="max-w-full"
controls
>
<source
:src="URL.createObjectURL(audio)"
>
</audio>
<q-btn
:label="$t('Attach')"
class="my-1"
color="green"
icon="attachment"
size="sm"
@click="attachAudio(audio)"
/>
</div>
</div>
</template>
<script>
import {ref} from "vue";
import {reactive, toRefs} from "vue";
export default {
name: "AudioRecorder",
setup() {
const isRecording = ref(false)
const audioList = ref([]);
props: {
multiple: {
type: Boolean,
required: false,
default: true
},
},
setup(props, {emit}) {
const recorderState = reactive({
isRecording: false,
audioList: [],
});
let mediaRecorder = null;
let mediaChunks = [];
@ -37,16 +74,17 @@ export default {
mediaChunks.push(e.data)
};
mediaRecorder.onstop = e => {
const blob = new Blob(mediaChunks, {type: 'audio/ogg; codecs=opus'});
const audioUrl = URL.createObjectURL(blob);
stream.getAudioTracks()[0].stop();
const audioItem = new Blob(mediaChunks, {type: 'audio/ogg; codecs=opus'});
mediaChunks = [];
audioList.value.push(audioUrl);
recorderState.audioList.push(audioItem);
};
mediaRecorder.start();
isRecording.value = true;
recorderState.isRecording = true;
})
.catch(console.log);
}
@ -56,18 +94,33 @@ export default {
return;
}
if (false === props.multiple && recorderState.audioList.length > 0) {
recorderState.audioList.shift();
}
mediaRecorder.stop();
mediaRecorder.stream.getAudioTracks()[0].stop();
isRecording.value = false;
recorderState.isRecording = false;
}
function attachAudio(audio) {
emit('attach-audio', audio);
const index = recorderState.audioList.indexOf(audio);
if (index >= 0) {
recorderState.audioList.splice(index, 1);
}
}
return {
record,
stop,
audioList,
isRecording
...toRefs(recorderState),
attachAudio,
URL
};
}
},
emits: ['attachAudio'],
}
</script>

@ -15,9 +15,37 @@
<slot></slot>
</v-col>
<v-col md="3">
<div v-text="$t('Atachments')" class="text-h6"/>
<div
v-if="item.attachments && item.attachments.length > 0"
>
<div
class="text-h6"
v-text="$t('Atachments')"
/>
<ul>
<li
v-for="(attachment, index) in item.attachments"
:key="index"
class="my-2"
>
<audio
v-if="attachment.type.indexOf('audio') === 0"
class="max-w-full"
controls
>
<source
:src="URL.createObjectURL(attachment)"
>
</audio>
</li>
</ul>
<hr class="my-2">
</div>
<AudioRecorder></AudioRecorder>
<AudioRecorder
@attach-audio="attachAudios"
/>
</v-col>
</v-row>
</v-container>
@ -27,14 +55,14 @@
<script>
import has from 'lodash/has';
import useVuelidate from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import {required} from '@vuelidate/validators';
import AudioRecorder from "../AudioRecorder";
export default {
name: 'MessageForm',
components: {AudioRecorder},
setup() {
return { v$: useVuelidate() }
return {v$: useVuelidate(), URL}
},
props: {
values: {
@ -43,11 +71,13 @@ export default {
},
errors: {
type: Object,
default: () => {}
default: () => {
}
},
initialValues: {
type: Object,
default: () => {}
default: () => {
}
},
},
data() {
@ -89,6 +119,15 @@ export default {
return this.errors || {};
}
},
methods: {
attachAudios(audio) {
if (!this.item.attachments) {
this.item.attachments = [];
}
this.item.attachments.push(audio);
}
},
validations: {
item: {
title: {

@ -14,6 +14,7 @@ import personalFileService from './services/personalfile';
import resourceLinkService from './services/resourcelink';
import resourceNodeService from './services/resourcenode';
import messageService from './services/message';
import messageAttachmentService from './services/messageattachment';
import messageRelUserService from './services/messagereluser';
import userService from './services/user';
import userGroupService from './services/usergroup';
@ -95,6 +96,13 @@ store.registerModule(
})
);
store.registerModule(
'messageattachment',
makeCrudModule({
service: messageAttachmentService
})
);
store.registerModule(
'messagereluser',
makeCrudModule({

@ -0,0 +1,3 @@
import makeService from './api';
export default makeService('message_attachments');

@ -5,64 +5,62 @@
/>
<MessageForm
ref="createForm"
:values="item"
:errors="violations"
:values="item"
>
<!-- @input="v$.item.receiversTo.$touch()"-->
<VueMultiselect
id="to"
placeholder="To"
v-model="item.receiversTo"
:allow-empty="false"
:internal-search="false"
:loading="isLoadingSelect"
:options="usersTo"
:multiple="true"
:options="usersTo"
:searchable="true"
:internal-search="false"
@search-change="asyncFindTo"
limit-text="3"
limit="3"
label="username"
limit="3"
limit-text="3"
placeholder="To"
track-by="id"
:allow-empty="false"
@search-change="asyncFindTo"
/>
<VueMultiselect
id="cc"
placeholder="Cc"
v-model="item.receiversCc"
:allow-empty="true"
:internal-search="false"
:loading="isLoadingSelect"
:options="usersCc"
:multiple="true"
:options="usersCc"
:searchable="true"
:internal-search="false"
@search-change="asyncFindCc"
limit-text="3"
limit="3"
label="username"
limit="3"
limit-text="3"
placeholder="Cc"
track-by="id"
:allow-empty="true"
@search-change="asyncFindCc"
/>
<!-- @filter-abort="abortFilterFn"-->
<!-- <q-select-->
<!-- filled-->
<!-- v-model="item.receivers"-->
<!-- use-input-->
<!-- use-chips-->
<!-- :options="users"-->
<!-- input-debounce="0"-->
<!-- label="Lazy filter"-->
<!-- @filter="asyncFind"-->
<!-- style="width: 250px"-->
<!-- hint="With use-chips"-->
<!-- :error-message="receiversErrors"-->
<!-- />-->
<!-- <q-select-->
<!-- filled-->
<!-- v-model="item.receivers"-->
<!-- use-input-->
<!-- use-chips-->
<!-- :options="users"-->
<!-- input-debounce="0"-->
<!-- label="Lazy filter"-->
<!-- @filter="asyncFind"-->
<!-- style="width: 250px"-->
<!-- hint="With use-chips"-->
<!-- :error-message="receiversErrors"-->
<!-- />-->
<TinyEditor
v-model="item.content"
required
:init="{
skin_url: '/build/libs/tinymce/skins/ui/oxide',
content_css: '/build/libs/tinymce/skins/content/default/content.css',
@ -78,18 +76,20 @@
'insertdatetime media table paste wordcount emoticons'
],
toolbar: 'undo redo | bold italic underline strikethrough | insertfile image media template link | fontselect fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist | forecolor backcolor removeformat | pagebreak | charmap emoticons | fullscreen preview save print | code codesample | ltr rtl',
}
"
}"
required
/>
</MessageForm>
<Loading :visible="isLoading" />
<Loading
:visible="isLoading"
/>
</template>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
<script>
import {mapActions, mapGetters, useStore} from 'vuex';
import { createHelpers } from 'vuex-map-fields';
import {mapActions, mapGetters} from 'vuex';
import {createHelpers} from 'vuex-map-fields';
import MessageForm from '../../components/message/Form.vue';
import Loading from '../../components/Loading.vue';
import Toolbar from '../../components/Toolbar.vue';
@ -99,9 +99,10 @@ import axios from "axios";
import {ENTRYPOINT} from "../../config/entrypoint";
import useVuelidate from "@vuelidate/core";
import VueMultiselect from 'vue-multiselect'
const servicePrefix = 'Message';
const { mapFields } = createHelpers({
const {mapFields} = createHelpers({
getterType: 'message/getField',
mutationType: 'message/updateField'
});
@ -116,12 +117,12 @@ export default {
MessageForm,
VueMultiselect
},
setup () {
setup() {
const usersTo = ref([]);
const usersCc = ref([]);
const isLoadingSelect = ref(false);
function asyncFind (query) {
function asyncFind(query) {
if (query.toString().length < 3) {
throw new Error('error');
}
@ -175,6 +176,17 @@ export default {
}),
},
methods: {
onCreated(message) {
if (this.item.attachments) {
this.item.attachments.forEach(attachment => {
this.createWithFormData({
messageId: message.id,
file: attachment
});
})
}
},
...mapActions('messageattachment', ['createWithFormData']),
...mapActions('message', ['create', 'reset'])
}
};

@ -0,0 +1,51 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller\Api;
use Chamilo\CoreBundle\Entity\Message;
use Chamilo\CoreBundle\Entity\MessageAttachment;
use Chamilo\CoreBundle\Repository\Node\MessageAttachmentRepository;
use Doctrine\ORM\EntityManager;
use Exception;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class CreateMessageAttachmentAction extends BaseResourceFileAction
{
/**
* @throws Exception
*/
public function __invoke(Request $request, MessageAttachmentRepository $repo, EntityManager $em): MessageAttachment
{
/** @var UploadedFile $uploadedFile */
$uploadedFile = $request->files->get('file');
if (empty($uploadedFile)) {
throw new BadRequestHttpException('file is required');
}
$messageRepo = $em->getRepository(Message::class);
$message = $messageRepo->find($request->get('messageId'));
$attachment = (new MessageAttachment())
->setFilename($uploadedFile->getFilename())
->setMessage($message)
->setParent($message->getSender())
->setCreator($message->getSender())
;
$message->addAttachment($attachment);
$em->persist($attachment);
$repo->addFile($attachment, $uploadedFile);
$em->flush();
return $attachment;
}
}

@ -234,7 +234,7 @@ class Message
/**
* @var Collection|MessageAttachment[]
*
* @ORM\OneToMany(targetEntity="MessageAttachment", mappedBy="message", cascade={"remove"})
* @ORM\OneToMany(targetEntity="MessageAttachment", mappedBy="message", cascade={"remove", "persist"})
*/
protected Collection $attachments;

@ -6,6 +6,8 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use Chamilo\CoreBundle\Controller\Api\CreateMessageAttachmentAction;
use Doctrine\ORM\Mapping as ORM;
/**
@ -14,6 +16,39 @@ use Doctrine\ORM\Mapping as ORM;
* @ORM\Table(name="message_attachment")
* @ORM\Entity(repositoryClass="Chamilo\CoreBundle\Repository\Node\MessageAttachmentRepository")
*/
#[ApiResource(
collectionOperations: [
'get',
'post' => [
'controller' => CreateMessageAttachmentAction::class,
'deserialize' => false,
'validation_groups' => ['Default', 'message_attachment:create'],
'openapi_context' => [
'requestBody' => [
'content' => [
'multipart/form-data' => [
'schema' => [
'type' => 'object',
'properties' => [
'file' => [
'type' => 'string',
'format' => 'binary',
],
'messageId' => [
'type' => 'integer',
],
],
],
],
],
],
],
],
],
iri: 'http://schema.org/MediaObject',
itemOperations: ['get'],
normalizationContext: ['groups' => 'message:read'],
)]
class MessageAttachment extends AbstractResource implements ResourceInterface
{
/**
@ -39,7 +74,7 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
protected int $size;
/**
* @ORM\ManyToOne(targetEntity="Chamilo\CoreBundle\Entity\Message", inversedBy="attachments")
* @ORM\ManyToOne(targetEntity="Chamilo\CoreBundle\Entity\Message", inversedBy="attachments", cascade={"persist"})
* @ORM\JoinColumn(name="message_id", referencedColumnName="id", nullable=false)
*/
protected Message $message;
@ -61,9 +96,14 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->getFilename();
}
public function setPath(string $path): self
public function getFilename(): string
{
$this->path = $path;
return $this->filename;
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
return $this;
}
@ -78,9 +118,9 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->path;
}
public function setComment(string $comment): self
public function setPath(string $path): self
{
$this->comment = $comment;
$this->path = $path;
return $this;
}
@ -95,9 +135,9 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->comment;
}
public function setSize(int $size): self
public function setComment(string $comment): self
{
$this->size = $size;
$this->comment = $comment;
return $this;
}
@ -107,9 +147,9 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->size;
}
public function setMessage(Message $message): self
public function setSize(int $size): self
{
$this->message = $message;
$this->size = $size;
return $this;
}
@ -119,16 +159,16 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->message;
}
public function setFilename(string $filename): self
public function setMessage(Message $message): self
{
$this->filename = $filename;
$this->message = $message;
return $this;
}
public function getFilename(): string
public function getResourceIdentifier(): int
{
return $this->filename;
return $this->getId();
}
/**
@ -141,11 +181,6 @@ class MessageAttachment extends AbstractResource implements ResourceInterface
return $this->id;
}
public function getResourceIdentifier(): int
{
return $this->getId();
}
public function getResourceName(): string
{
return $this->getFilename();

@ -168,43 +168,86 @@ class MessageRepositoryTest extends AbstractApiTest
{
self::bootKernel();
$em = $this->getEntityManager();
$user1 = $this->getUser('admin');
$user2 = $this->createUser('user2');
$messageAttachmentRepo = self::getContainer()->get(MessageAttachmentRepository::class);
$messageRepo = self::getContainer()->get(MessageRepository::class);
$user1Token = $this->getUserTokenFromUser($user1);
$admin = $this->getUser('admin');
$testUser = $this->createUser('test');
// Create message.
$message = (new Message())
->setTitle('hello')
->setContent('content')
->setMsgType(Message::MESSAGE_TYPE_INBOX)
->setSender($admin)
->addReceiver($testUser)
;
$client = $this->createClientWithCredentials($user1Token);
$this->assertHasNoEntityViolations($message);
$messageRepo->update($message);
$responseMessage = $client->request(
'POST',
'/api/messages',
[
'json' => [
'msgType' => Message::MESSAGE_TYPE_INBOX,
'title' => 'Message title',
'content' => 'Message content',
'receivers' => [
[
'receiver' => "/api/users/{$user2->getId()}",
'receiverType' => MessageRelUser::TYPE_TO,
],
],
'sender' => "/api/users/{$user1->getId()}",
],
]
);
$file = $this->getUploadedFile();
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains(
[
'@context' => '/api/contexts/Message',
'@type' => 'Message',
'sender' => [
'@id' => "/api/users/{$user1->getId()}",
],
'receiversTo' => [
[
'@type' => 'MessageRelUser',
'receiver' => [
'@id' => "/api/users/{$user2->getId()}",
],
'receiverType' => MessageRelUser::TYPE_TO,
],
],
'msgType' => Message::MESSAGE_TYPE_INBOX,
'title' => 'Message title',
'content' => 'Message content',
]
);
$attachment = (new MessageAttachment())
->setFilename($file->getFilename())
->setMessage($message)
->setParent($message->getSender())
->setCreator($message->getSender())
;
$message->addAttachment($attachment);
$messageId = $responseMessage->toArray()['id'];
$em->persist($attachment);
$messageAttachmentRepo->addFile($attachment, $file);
$em->flush();
$file = $this->getUploadedFile();
$em->clear();
$responseAttachment = $client->request(
'POST',
'/api/message_attachments',
[
'headers' => [
'Content-Type' => 'multipart/form-data',
],
'extra' => [
'files' => [
'file' => $file,
],
'parameters' => [
'messageId' => $messageId,
],
],
]
);
$this->assertSame(1, $messageAttachmentRepo->count([]));
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains(
[
'@context' => '/api/contexts/MessageAttachment',
'@type' => 'http://schema.org/MediaObject',
]
);
}
public function testDeleteMessage(): void

Loading…
Cancel
Save