TT#20530 Implement playable voicemail

What has been done:
- TT#27761, Conversations: Create custom modules for conversation
  items
- TT#27763, Conversations: Implement "play voicemail" button with
  download functionality
- TT#27764, Conversations: Implement hashed id based on conversation
  item type, call_type and id

Change-Id: If772b3ed7e7db1dd7b93e48aacf1ce0d93acf5a8
changes/06/17406/16
raxelsen 8 years ago
parent 3db9b924d7
commit 54c0aa0f71

@ -96,6 +96,10 @@ Now you can log in to csc with one of the normal subscriber you just created. UR
### How to add new npm package
1. Remove the package if you've already installed it
`npm remove <package> <--save-dev || --save>`
1. Ensure that you have a clean node_modules folder
```

48
npm-shrinkwrap.json generated

@ -169,9 +169,9 @@
"dev": true
},
"asn1.js": {
"version": "4.9.1",
"version": "4.9.2",
"from": "asn1.js@>=4.0.0 <5.0.0",
"resolved": "https://npm-registry.sipwise.com/asn1.js/-/asn1.js-4.9.1.tgz",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.9.2.tgz",
"dev": true
},
"assert": {
@ -1185,9 +1185,9 @@
"dev": true
},
"crypto-browserify": {
"version": "3.11.1",
"from": "crypto-browserify@>=3.11.0 <4.0.0",
"resolved": "https://npm-registry.sipwise.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz",
"version": "3.12.0",
"from": "crypto-browserify@latest",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
"dev": true
},
"css": {
@ -2016,6 +2016,12 @@
"resolved": "https://npm-registry.sipwise.com/file-loader/-/file-loader-0.11.2.tgz",
"dev": true
},
"file-saver": {
"version": "1.3.3",
"from": "file-saver@latest",
"resolved": "https://npm-registry.sipwise.com/file-saver/-/file-saver-1.3.3.tgz",
"dev": true
},
"filename-regex": {
"version": "2.0.1",
"from": "filename-regex@>=2.0.0 <3.0.0",
@ -2107,9 +2113,9 @@
"dev": true
},
"fsevents": {
"version": "1.1.2",
"version": "1.1.3",
"from": "fsevents@>=1.0.0 <2.0.0",
"resolved": "https://npm-registry.sipwise.com/fsevents/-/fsevents-1.1.2.tgz",
"resolved": "https://npm-registry.sipwise.com/fsevents/-/fsevents-1.1.3.tgz",
"dev": true,
"optional": true,
"dependencies": {
@ -2267,8 +2273,7 @@
"version": "2.0.5",
"from": "cryptiles@2.0.5",
"resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz",
"dev": true,
"optional": true
"dev": true
},
"dashdash": {
"version": "1.14.1",
@ -2313,6 +2318,13 @@
"dev": true,
"optional": true
},
"detect-libc": {
"version": "1.0.2",
"from": "detect-libc@^1.0.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.2.tgz",
"dev": true,
"optional": true
},
"ecc-jsbn": {
"version": "0.1.1",
"from": "ecc-jsbn@0.1.1",
@ -2426,8 +2438,7 @@
"version": "3.1.3",
"from": "hawk@3.1.3",
"resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz",
"dev": true,
"optional": true
"dev": true
},
"hoek": {
"version": "2.16.3",
@ -2583,9 +2594,9 @@
"optional": true
},
"node-pre-gyp": {
"version": "0.6.36",
"from": "node-pre-gyp@^0.6.36",
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz",
"version": "0.6.39",
"from": "node-pre-gyp@^0.6.39",
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz",
"dev": true,
"optional": true
},
@ -2749,8 +2760,7 @@
"version": "1.0.9",
"from": "sntp@1.0.9",
"resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz",
"dev": true,
"optional": true
"dev": true
},
"sshpk": {
"version": "1.13.0",
@ -5004,6 +5014,12 @@
"resolved": "https://npm-registry.sipwise.com/randombytes/-/randombytes-2.0.5.tgz",
"dev": true
},
"randomfill": {
"version": "1.0.3",
"from": "randomfill@>=1.0.3 <2.0.0",
"resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.3.tgz",
"dev": true
},
"range-parser": {
"version": "1.2.0",
"from": "range-parser@>=1.2.0 <1.3.0",

@ -33,6 +33,7 @@
"chai": "^4.1.2",
"colors": "^1.1.2",
"connect-history-api-fallback": "^1.1.0",
"crypto-browserify": "3.12.0",
"css-loader": "^0.28.7",
"es6-promise": "^4.1.1",
"eslint": "^4.8.0",
@ -48,6 +49,7 @@
"express": "^4.16.1",
"extract-text-webpack-plugin": "^3.0.0",
"file-loader": "^0.11.1",
"file-saver": "1.3.3",
"friendly-errors-webpack-plugin": "^1.1.3",
"glob": "^7.1.2",
"google-libphonenumber": "3.0.7",

@ -1,6 +1,8 @@
import Vue from 'vue';
import _ from 'lodash';
import { saveAs } from 'file-saver'
import Vue from 'vue'
import _ from 'lodash'
import crypto from 'crypto-browserify'
import { getJsonBody } from './utils'
export function getConversations(id, page, rows) {
@ -11,7 +13,22 @@ export function getConversations(id, page, rows) {
.then(result => {
let jsonBody = getJsonBody(result.body);
if (_.has(jsonBody, "_embedded.ngcp:conversations")) {
resolve(jsonBody._embedded['ngcp:conversations']);
let list = [];
_.forEach(jsonBody._embedded['ngcp:conversations'], function(item) {
let inputString = `${item.type}${item.call_type}${item.id}`;
let id = crypto.createHash('sha256').update(inputString).digest('base64');
item._id = id;
if (item._links['ngcp:voicemailrecordings']) {
item.voicemail = item._links['ngcp:voicemailrecordings'].href;
};
delete item._links;
if (item.type == 'call') {
item.type = item.call_type != 'call' ? 'callforward'
: item.type;
};
list.push(item);
});
resolve(list);
} else {
reject(new Error('No items returned for this page.'))
};
@ -20,3 +37,24 @@ export function getConversations(id, page, rows) {
});
});
}
export function downloadVoiceMail(id) {
return new Promise((resolve, reject)=>{
Vue.http.get('/api/voicemailrecordings/' + id, { responseType: 'blob' })
.then(res => {
return res.blob();
}).then(voicemail => {
saveAs(voicemail, "voicemail-" + id + '.wav');
resolve();
}).catch((err)=>{
reject(err);
});
});
}

@ -0,0 +1,151 @@
<template>
<q-card class="conversation-card" :id="conversation._id">
<csc-collapsible :icon="getCollapsibleIcons()"
:label="getCollapsibleLabel()"
:sublabel="conversation.start_time | readableDate">
<div v-if="isType('call') || isType('voicemail')">
<ul>
<li>
<strong>
{{ $t('pages.conversations.card.duration') }}:
</strong> {{ conversation.duration }}</li>
<li v-if="isType('voicemail')">
<strong>
{{ $t('pages.conversations.card.folder') }}:
</strong> {{ conversation.folder }}</li>
</ul>
</div>
</csc-collapsible>
<div v-if="!isType('fax')">
<q-card-separator />
<q-card-actions align="center">
<q-btn flat round small color="primary" icon="call">
{{ $t('pages.conversations.buttons.call') }}
<q-popover ref="popover">
<q-list separator link>
<q-item @click="call('audioOnly'),
$refs.popover.close()">
{{ $t('pages.conversations.buttons.audioCall') }}
</q-item>
<q-item @click="call('audioVideo'),
$refs.popover.close()">
{{ $t('pages.conversations.buttons.videoCall') }}
</q-item>
</q-list>
</q-popover>
</q-btn>
<div v-if="isType('voicemail')">
<q-btn flat round small color="primary" icon="play_arrow"
@click="downloadVoiceMail(conversation.id)">
{{ $t('pages.conversations.buttons.play') }}
</q-btn>
</div>
</q-card-actions>
</div>
</q-card>
</template>
<script>
import Vue from 'vue'
import crypto from 'crypto-browserify'
import CscCollapsible from './card/CscCollapsible'
import { mapGetters } from 'vuex'
import { QBtn, QCardActions, QCard, QCardSeparator,
QPopover, QItem, QList } from 'quasar-framework'
import numberFormat from '../filters/number-format'
export default {
name: 'csc-conversation',
props: [
'conversation'
],
components: {
QBtn,
QCard,
QCardActions,
QCardSeparator,
QPopover,
QItem,
QList,
CscCollapsible
},
computed: {
...mapGetters('conversations', [
'getCardId'
])
},
methods: {
downloadVoiceMail(id) {
this.$store.dispatch('conversations/downloadVoiceMail', id);
},
call(localMedia) {
let conversation = this.conversation;
let number = conversation.direction == 'out' ?
conversation.callee : conversation.caller;
this.$store.dispatch('call/start',
{ number: number, localMedia: localMedia });
},
getCollapsibleIcons() {
let conversation = this.conversation;
let directionIcon = conversation.direction == 'out' ? 'call_made' :
'call_received';
switch (conversation.type) {
case 'call':
return 'phone ' + directionIcon;
break;
case 'callforward':
return 'call_merge ' + directionIcon;
break;
case 'voicemail':
return 'voicemail ' + directionIcon;
break;
case 'fax':
return 'insert_drive_file ' + directionIcon;
break; case 'sms':
return 'txtsms ' + directionIcon;
break;
};
},
getCollapsibleLabel() {
let conversation = this.conversation;
let prefix;
if (!conversation.status || ['ok', 'SUCCESS'].indexOf(conversation.status) > -1) {
prefix = this.$t('pages.conversations.labels.successful');
} else {
prefix = this.$t('pages.conversations.labels.unsuccessful');
}
let direction = conversation.direction == 'in' ?
this.$t('pages.conversations.labels.from') :
this.$t('pages.conversations.labels.to');
let number = conversation.caller;
if(conversation.direction === 'out') {
number = conversation.callee;
}
return `${prefix} ${conversation.type} ${direction} ${numberFormat(number)}`;
},
isType(type) {
return this.conversation.type == type;
},
getVoicemailUrl() {
return window.location.origin + this.conversation.voicemail;
}
}
}
</script>
<style lang="stylus">
@import '~variables'
.conversation-card
padding 15px
.q-btn
margin-bottom -10px
.voicemail-controls
height 46px
padding 0 7px
.voicemail-outer
width 110px
height 50px
margin-left 10px
.voicemail-inner
position absolute
width 110px
</style>

@ -0,0 +1,40 @@
<template>
<div>
<q-infinite-scroll :handler="loadMore" :offset=1 ref="infinite">
<csc-conversation v-for="(conversation, index) in conversations"
:conversation="conversation" :key="conversation._id">
</csc-conversation>
<div slot="message" class="row justify-center">
<q-spinner-dots :size="40"></q-spinner-dots>
</div>
</q-infinite-scroll>
</div>
</template>
<script>
import CscConversation from './CscConversation'
import { QInfiniteScroll, QSpinnerDots } from 'quasar-framework'
export default {
name: 'csc-conversations',
props: ['conversations'],
components: {
CscConversation,
QInfiniteScroll,
QSpinnerDots
},
methods: {
loadMore(index, done) {
this.$store.dispatch('conversations/loadConversations')
.then(() => {
done();
}).catch((err) => {
done();
this.$refs.infinite.stop();
});
}
}
}
</script>
<style lang="stylus">
</style>

@ -1,59 +1,12 @@
<template>
<csc-page :title="$t('pages.conversations.title')">
<q-infinite-scroll :handler="loadMore" :offset=1 ref="infinite">
<q-card v-for="(conversation, index) in conversations"
:key="conversation.caller"
class="conversation-card">
<csc-collapsible :icon="getCollapsibleIcons(conversation)"
:label="getCollapsibleLabel(conversation)"
:sublabel="conversation.start_time | readableDate">
<div v-if="isCall(conversation.type) || isVoicemail(conversation.type)">
<ul>
<li>
<strong>
{{ $t('pages.conversations.card.duration') }}:
</strong> {{ conversation.duration }}</li>
<li v-if="isVoicemail(conversation.type)">
<strong>
{{ $t('pages.conversations.card.folder') }}:
</strong> {{ conversation.folder }}</li>
</ul>
</div>
</csc-collapsible>
<div v-if="!isFax(conversation.type)">
<q-card-separator />
<q-card-actions align="center">
<q-btn flat round small color="primary" icon="call">
{{ $t('pages.conversations.buttons.call') }}
<q-popover ref="popover">
<q-list separator link>
<q-item @click="call(conversation,
'audioOnly'), $refs.popover[index].close()">
{{ $t('pages.conversations.buttons.audioCall') }}
</q-item>
<q-item @click="call(conversation,
'audioVideo'), $refs.popover[index].close()">
{{ $t('pages.conversations.buttons.videoCall') }}
</q-item>
</q-list>
</q-popover>
</q-btn>
</q-card-actions>
</div>
</q-card>
<div slot="message" class="row justify-center">
<q-spinner-dots :size="40"></q-spinner-dots>
</div>
</q-infinite-scroll>
<csc-conversations :conversations="conversations"></csc-conversation>
</csc-page>
</template>
<script>
import CscPage from '../CscPage'
import CscCollapsible from '../card/CscCollapsible'
import { QBtn, QCardActions, QCard, QCardSeparator, QInfiniteScroll,
QPopover, QList, QItem, QSpinnerDots } from 'quasar-framework'
import numberFormat from '../../filters/number-format'
import CscConversations from '../CscConversations'
export default {
data () {
return {
@ -61,99 +14,18 @@
},
components: {
CscPage,
QBtn,
QCard,
QCardActions,
QCardSeparator,
CscCollapsible,
QInfiniteScroll,
QPopover,
QList,
QItem,
QSpinnerDots
CscConversations
},
computed: {
conversations() {
return this.$store.state.conversations.conversations;
}
},
methods: {
call(conversation, localMedia) {
let number = conversation.direction == 'out' ? conversation.callee : conversation.caller;
this.$store.dispatch('call/start',
{ number: number, localMedia: localMedia });
},
loadMore(index, done) {
this.$store.dispatch('conversations/loadConversations')
.then(() => {
done();
}).catch((err) => {
done();
this.$refs.infinite.stop();
});
},
getCollapsibleIcons(item) {
let directionIcon = item.direction == 'out' ? 'call_made' :
'call_received';
switch (item.type) {
case 'call':
return 'phone ' + directionIcon;
break;
case 'call forward':
return 'call_merge ' + directionIcon;
break;
case 'voicemail':
return 'voicemail ' + directionIcon;
break;
case 'fax':
return 'insert_drive_file ' + directionIcon;
break;
case 'sms':
return 'txtsms ' + directionIcon;
break;
};
},
getCollapsibleLabel(item) {
let prefix = item.status == 'ok' ?
this.$t('pages.conversations.labels.successful')
: this.$t('pages.conversations.labels.unsuccessful');
let direction = item.direction == 'in' ?
this.$t('pages.conversations.labels.from') :
this.$t('pages.conversations.labels.to');
let number = item.caller;
if(item.direction === 'out') {
number = item.callee;
}
return `${prefix} ${item.type} ${direction} ${numberFormat(number)}`;
},
isCall(type) {
return type == 'call';
},
isVoicemail(type) {
return type == 'voicemail';
},
isFax(type) {
return type == 'fax';
},
isXmpp(type) {
return type == 'xmpp';
},
isSms(type) {
return type == 'sms';
},
isCallForward(type) {
return type == 'call forward';
}
}
}
</script>
<style lang="stylus">
@import '~variables'
.conversation-card
padding 15px
.q-btn
margin-bottom -10px
.q-infinite-scroll-message
margin-bottom 50px
</style>

@ -92,7 +92,8 @@
"buttons": {
"call": "CALL",
"audioCall": "Audio Call",
"videoCall": "Video Call"
"videoCall": "Video Call",
"play": "Play"
},
"card": {
"date": "Date",

@ -1,8 +1,8 @@
'use strict';
'use strict'
import _ from 'lodash';
import { getConversations } from '../api/conversations';
import _ from 'lodash'
import { getConversations, downloadVoiceMail } from '../api/conversations'
export default {
namespaced: true,
@ -14,16 +14,7 @@ export default {
},
mutations: {
loadConversations(state, options) {
let list = [];
_.forEach(options, function(item) {
delete item._links;
if (item.type == 'call') {
item.type = item.call_type != 'call' ? 'call forward'
: item.type;
};
list.push(item);
})
state.conversations = state.conversations.concat(list);
state.conversations = state.conversations.concat(options);
state.page++;
}
},
@ -39,6 +30,15 @@ export default {
reject(err);
});
});
},
downloadVoiceMail(context, id) {
return new Promise((resolve, reject)=>{
downloadVoiceMail(id).then(()=>{
resolve();
}).catch((err)=>{
reject(err);
});
});
}
}
};

@ -5,7 +5,7 @@ FROM docker.mgm.sipwise.com/sipwise-stretch:latest
# is updated with the current date. It will force refresh of all
# of the base images and things like `apt-get update` won't be using
# old cached versions when the Dockerfile is built.
ENV REFRESHED_AT 2017-10-06
ENV REFRESHED_AT 2017-12-15
ENV DEBIAN_FRONTEND noninteractive
ENV DISPLAY=:0

@ -3,6 +3,7 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
import crypto from 'crypto-browserify'
import { getConversations } from '../../src/api/conversations';
import { assert } from 'chai';
@ -13,6 +14,8 @@ describe('Conversations', function(){
const subscriberId = 123;
it('should get all data regarding conversations', function(done){
let inputString = 'voicemailundefined1';
let hashedId = crypto.createHash('sha256').update(inputString).digest('base64');
let innerData = [{
"_links" : {
@ -24,37 +27,59 @@ describe('Conversations', function(){
"name": "ngcp",
"templated": true
},
"ngcp:calls" : {
"href" : "/api/calls/5"
},
"ngcp:conversations": {
"href" : "/api/conversations/5?type=call"
"href": "/api/conversations/1?type=voicemail"
},
"ngcp:voicemailrecordings": {
"href": "/api/voicemailrecordings/1"
},
"ngcp:voicemails": {
"href": "/api/voicemails/1"
},
"profile": {
"href": "http://purl.org/sipwise/ngcp-api/"
},
"self": {
"href" : "/api/conversations/5?type=call"
"href": "/api/conversations/1?type=voicemail"
}
},
"call_id" : "cT1miqD5Nw",
"call_type" : "cfu",
"callee" : "vmu43993006@voicebox.local",
"call_id": "kp55kEGtNp",
"callee": "43993006",
"caller": "43993006",
"direction" : "out",
"duration" : "0:00:19.672",
"id" : 5,
"rating_status" : "ok",
"start_time" : "2017-11-10 08:51:10.452",
"status" : "ok",
"type" : "call"
"context": "voicemailcaller_unavail",
"direction": "in",
"duration": "15",
"filename": "voicemail-0.wav",
"folder": "Old",
"id": 1,
"start_time": "2017-12-07 16:22:04",
"type": "voicemail",
"voicemail_subscriber_id": 235
}];
let data = {
"_embedded": {
"ngcp:conversations": innerData
}
};
let innerDataWithoutLinks = [{
"call_id": "kp55kEGtNp",
"callee": "43993006",
"caller": "43993006",
"context": "voicemailcaller_unavail",
"direction": "in",
"duration": "15",
"filename": "voicemail-0.wav",
"folder": "Old",
"id": 1,
"start_time": "2017-12-07 16:22:04",
"type": "voicemail",
"voicemail_subscriber_id": 235,
"_id": hashedId,
"voicemail": "/api/voicemailrecordings/1"
}];
Vue.http.interceptors = [];
Vue.http.interceptors.unshift((request, next)=>{
next(request.respondWith(JSON.stringify(data), {
@ -62,7 +87,7 @@ describe('Conversations', function(){
}));
});
getConversations(subscriberId).then((result)=>{
assert.deepEqual(result, innerData);
assert.deepEqual(result, innerDataWithoutLinks);
done();
}).catch((err)=>{
done(err);

@ -13,32 +13,16 @@ describe('Conversations', function(){
};
let data = [
{
"_links": {
},
"call_type": "cfu",
"caller": "43993010",
"type": "call"
},
{
"_links": {
},
"caller": "43993011",
"type": "fax"
}
];
ConversationsModule.mutations.loadConversations(state, data);
assert.deepEqual(state.conversations, [
{
"call_type": "cfu",
"caller": "43993010",
"type": "call forward"
},
{
"caller": "43993011",
"type": "fax"
}
]);
assert.deepEqual(state.conversations, data);
});
});

Loading…
Cancel
Save