Voice Javascript SDK: using device.updateOptions() to change incoming sound to emulate a call waiting notification - twilio

Trying to implement simple call waiting with a different incoming sound for any calls that come in while the device is already on an active call.
Upon the answer event we are using device.updateOptions() to update the sound, and the same to revert it back on the disconnect event.
Out of the box we would get the standard incoming sound for that second call. But using the below we do not get any sound on the 2nd incoming call while there is an active call. I'm not sure what we are missing or if there is a better approach.
const appReducer = (state, action) => {
switch (action.type) {
case SET_DEVICE:
return {
...state,
device: action.payload,
};
case SET_ADMIN_DETAIL:
return {
...state,
adminDetail: action.payload,
};
case SET_INCOMING_CALL: {
// when incoming calls set to null (or after calls end)
if (!action.payload.call) {
/** if two calls in progress we need to check which one we have set to null so compare the outboundConnectionId ids with
saved incoming call **/
if (state.incomingCall && state.incomingCall.outboundConnectionId === action.payload.prevStatusCall.outboundConnectionId) {
return {
...state,
incomingCall: null,
};
} else if (state.secondIncomingCall && state.secondIncomingCall.outboundConnectionId === action.payload.prevStatusCall.outboundConnectionId) {
/** if two calls in progress we need to check which one we have set to null so compare the outboundConnectionId ids with
saved second incoming call **/
return {
...state,
secondIncomingCall: null,
};
}
}
// when incoming calls intialized or accept
else {
/** if there is not any call (incomingCall, enqueuedCall, outgoingCall, secondIncomingCall) saved in our state
then treated as first call or update if first call outboundConnectionId is equal to call passed in params **/
if (
(!state.incomingCall && !state.enqueuedCall && !state.outgoingCall && !state.secondIncomingCall) ||
(state.incomingCall && action.payload.call.outboundConnectionId === state.incomingCall.outboundConnectionId)
) {
if (action.payload.event === "accept") {
console.log("First Call Accepted>>>>>>>>>>>>>>>>>>>>>");
state.device.updateOptions({
sounds: {
incoming: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/Short%20Marimba%20Notification%20Ding.mp3",
disconnect: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20connect.mp3",
outgoing: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20disconnect.mp3",
},
});
}
return {
...state,
incomingCall: action.payload.call,
};
} else {
/** if we cancel/end the first call then we treate the second call as first **/
if (!state.incomingCall) {
return {
...state,
incomingCall: action.payload.call,
secondIncomingCall: null,
};
} else {
/** else save the call obj in second call and use for the UI **/
return {
...state,
secondIncomingCall: action.payload.call,
};
}
}
}
// return {
// ...state,
// incomingCall: action.payload.call,
// };
}
case SET_MISSED_CALL_FLAG:
return {
...state,
missedCallFlag: action.payload,
};
case SET_OUT_GOING_CALL: {
if (action.payload.event === "accept") {
console.log("First Call Accepted>>>>>>>>>>>>>>>>>>>>>");
state.device.updateOptions({
sounds: {
incoming: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/Short%20Marimba%20Notification%20Ding.mp3",
disconnect: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20connect.mp3",
outgoing: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20disconnect.mp3",
},
});
}
return {
...state,
outgoingCall: action.payload.call,
};
}
case SET_CALL_TIME:
return {
...state,
callTime: action.payload === 0 ? 0 : state.callTime + action.payload,
};
case SET_ACTIVE:
return {
...state,
isActive: action.payload,
};
case SET_DISPLAY_LIVE_CHAT_PANEL:
return {
...state,
displayLiveChatPanel: action.payload,
};
case SET_DISPLAY_ON_CALL_PANEL:
return {
...state,
displayOnCallPanel: action.payload,
};
case SET_MUTE:
return {
...state,
mute: action.payload,
};
case SET_ENQUEUED:
return {
...state,
enqueued: action.payload,
};
case SET_CALL_ENQUEUED:
return {
...state,
enqueuedCall: action.payload,
enqueued: action.payload ? true : false,
};
case SET_OUT_GOING_USER:
return {
...state,
selectedOutgoingUser: action.payload,
};
default:
return state;
}
};
export const AppContext = createContext(initialState);
export const AppContextProvider = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);
const location = useLocation();
const dispatchFn = useDispatch();
function setAdminDetail(payload) {
dispatch({ type: SET_ADMIN_DETAIL, payload: payload });
}
function setDevice(payload) {
dispatch({ type: SET_DEVICE, payload: payload });
}
function setIncomingCall(payload) {
dispatch({ type: SET_INCOMING_CALL, payload: payload });
}
function setMissedCallFlag(payload) {
dispatch({ type: SET_MISSED_CALL_FLAG, payload: payload });
}
function setOutgoingCall(payload) {
dispatch({ type: SET_OUT_GOING_CALL, payload: payload });
}
function setCallTime(payload) {
dispatch({ type: SET_CALL_TIME, payload: payload });
}
function setIsActive(payload) {
dispatch({ type: SET_ACTIVE, payload: payload });
}
function setDisplayOnCallPanel(payload) {
dispatch({ type: SET_DISPLAY_ON_CALL_PANEL, payload: payload });
}
function setDisplayLiveChatPanel(payload) {
dispatch({ type: SET_DISPLAY_LIVE_CHAT_PANEL, payload: payload });
}
function setMute(payload) {
dispatch({ type: SET_MUTE, payload: payload });
}
function setCallEnqueue(payload) {
dispatch({ type: SET_CALL_ENQUEUED, payload: payload });
}
function setEnqueue(payload) {
dispatch({ type: SET_ENQUEUED, payload: payload });
}
function setSelectedOutgoingUser(payload) {
dispatch({ type: SET_OUT_GOING_USER, payload: payload });
}
const setUpDevice = async (adminDetail) => {
const result = await getData(
`${process.env.REACT_APP_TWILIO_SERVER}token?extension_name=${adminDetail?.twilio_call_extension?.name}&extension_id=${adminDetail?.twilio_call_extension?.id}`
);
const newDevice = new Device(result.token, {
sounds: {
disconnect: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20connect.mp3",
outgoing: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20disconnect.mp3",
},
logLevel: 1,
allowIncomingWhileBusy: true,
});
newDevice.register();
newDevice.on("registered", (twilioError, call) => {
console.log("registered: ", twilioError);
});
newDevice.on("error", (twilioError, call) => {
console.log("An error has occurred: ", twilioError);
});
newDevice.audio.on("", (twilioError, call) => {
console.log("An error has occurred: ", twilioError, call);
});
newDevice.on("incoming", (call) => {
setMissedCallFlag(true);
setDisplayLiveChatPanel(false);
call.on("accept", (call) => {
setIncomingCall({ call, event: "accept" });
setIsActive(true);
});
call.on("cancel", () => {
setDisplayOnCallPanel(false);
setIncomingCall({ call: null, prevStatusCall: call });
setIsActive(false);
setCallTime(0);
dispatch(
getRecentCalls({
id: adminDetail.twilio_call_extension.id,
offset: 0,
})
);
});
call.on("error", (error) => {
console.log("An error has occurred: ", error);
});
call.on("disconnect", (call) => {
setDisplayOnCallPanel(false);
setIncomingCall({ call: null, prevStatusCall: call, event: "disconnect" });
setIsActive(false);
setCallTime(0);
dispatch(
getRecentCalls({
id: adminDetail.twilio_call_extension.id,
offset: 0,
})
);
});
call.on("reject", () => {
setDisplayOnCallPanel(false);
console.log("The call was rejected.");
setIncomingCall({ call: null, prevStatusCall: call });
setIsActive(false);
setCallTime(0);
dispatch(
getRecentCalls({
id: adminDetail.twilio_call_extension.id,
offset: 0,
})
);
});
call.on("reconnected", () => {
console.log("The call has regained connectivity.");
});
setIncomingCall({ call });
});
newDevice.on("tokenWillExpire", async () => {
console.log("acascas");
let updatedToken = await getData(
`${process.env.REACT_APP_TWILIO_SERVER}token?extension_name=${adminDetail?.twilio_call_extension?.name}&extension_id=${adminDetail?.twilio_call_extension?.id}`
);
newDevice.updateToken(updatedToken.token);
setDevice(newDevice);
});
setDevice(newDevice);
};
const setCallOnMute = async () => {
let { incomingCall, outgoingCall, mute } = state;
let muted = !mute;
if (incomingCall) {
incomingCall.mute(muted);
setMute(muted);
} else if (outgoingCall) {
outgoingCall.mute(muted);
setMute(muted);
}
};
const enqueueCall = async (call_id) => {
const result = await getData(`${process.env.REACT_APP_TWILIO_SERVER}enqueue-call/${call_id}`);
if (result.status === 200) {
console.log(result);
setCallEnqueue(result.data);
setEnqueue(true);
return result.data;
} else {
return null;
}
};
const dialNumber = async (number, id, name) => {
let { adminDetail, device } = state;
let From = adminDetail?.twilio_call_extension?.direct_number;
let params = {
To: number,
From: From,
outboundcall: "true",
extension_id: adminDetail?.twilio_call_extension?.id,
createdBy: adminDetail?.id,
};
if (id) {
params.userId = id;
}
if (name) {
params.tempName = name;
}
const call = await device.connect({
params: params,
});
setOutGoingCallState(call);
setDisplayLiveChatPanel(false);
};
const setOutGoingCallState = (call) => {
setOutgoingCall({ call: null });
setIsActive(true);
setMute(false);
setOutgoingCall({ call: call });
call.on("disconnect", (nest_call) => {
setDisplayOnCallPanel(false);
console.log("disconnect");
setOutgoingCall({ call: null });
setIsActive(false);
setMute(false);
setSelectedOutgoingUser(null);
setCallTime(0);
dispatch(
getRecentCalls({
id: state.adminDetail.twilio_call_extension.id,
offset: 0,
})
);
});
call.on("accept", (nest_call) => {
setOutgoingCall({ call: nest_call, event: "accept" });
});
call.on("cancel", (nest_call) => {
setDisplayOnCallPanel(false);
setOutgoingCall({ call: null });
setIsActive(false);
setMute(false);
setSelectedOutgoingUser(null);
setCallTime(0);
});
};
useEffect(() => {
let interval = null;
if (state.isActive) {
interval = setInterval(() => {
setCallTime(1000);
}, 1000);
} else {
clearInterval(interval);
}
return () => {
clearInterval(interval);
};
}, [state.isActive]);
useEffect(() => {
let interval = null;
if (state.isActive) {
interval = setInterval(() => {
setCallTime(1000);
}, 1000);
} else {
clearInterval(interval);
}
return () => {
clearInterval(interval);
};
}, [state.isActive]);
// reset optons when there is no active call
useEffect(() => {
if (state.device && !state.incomingCall && !state.secondIncomingCall && !state.enqueuedCall && !state.outgoingCall) {
console.log("Both Calls Ends >>>>>>>>>>>>>>>>>>>>>");
state.device.updateOptions({
sounds: {
disconnect: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20connect.mp3",
outgoing: "https://method-platform.s3.us-east-1.amazonaws.com/phone-configuration/audios/call%20disconnect.mp3",
},
});
}
},

Related

'Location enable permission' alert disappear after few second(3 to 4 second) in react-native

I am using 'react-native-geolocation-service' library for enabling the location for the app, if location is disabled. So if location is disabled then permission alert is working fine in android but In IOS it is appear for few second like 2 or 3 second, after that it will close. Below is the sample of method.
static hasLocationPermissionIOS = async () => {
const status = await Geolocation.requestAuthorization('always');
if (status === 'granted') {
return 'GRANTED';
}
if (status === 'denied') {
return 'DENIED';
}
if (status === 'disabled') {
return 'DISABLED';
}
};
static hasLocationPermission = async () => {
if (Platform.OS === 'ios') {
Geolocation.requestAuthorization('whenInUse');
const hasPermission = await this.hasLocationPermissionIOS();
return hasPermission;
}
if (Platform.OS === 'android') {
const hasPermission = await this.hasLocationPermissionAndroid();
return hasPermission;
}
return false;
};
static hasLocationPermission = async () => {
if (Platform.OS === 'ios') {
Geolocation.requestAuthorization('whenInUse');
const hasPermission = await this.hasLocationPermissionIOS();
return hasPermission;
}
if (Platform.OS === 'android') {
const hasPermission = await this.hasLocationPermissionAndroid();
return hasPermission;
}
return false;
};
static getLocation = async () => {
const hasLocationPermission = await this.hasLocationPermission();
if (!hasLocationPermission) {
return;
}
return new Promise((resolve, reject = (error) => {}) => {
Geolocation.getCurrentPosition((position)=> {
resolve(position);
}, (error)=>{
resolve(error);
}, {
accuracy: {
android: 'high',
ios: 'best',
},
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 10000,
distanceFilter: 0,
forceRequestLocation: true,
showLocationDialog: true,
});
});
};
I referred the link but not able to find solution,
https://github.com/douglasjunior/react-native-get-location/issues/18
Thanks in advance!!!

IOS Notification Permission alert does not show

SDK Version: 39.0.0
Platforms(Android/iOS/web/all): All
I am not getting accept or decline notifications permissions alert when loading my app in production.
I have tried clearing certificates and keys and allowing expo to add everything from a clean slate, but still no luck. I am starting to think maybe it’s my code which is the reason why the alert doesn’t get fired.
import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import { Permissions } from "expo-permissions";
import { Notifications as Notifications2 } from "expo";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false
})
});
export default class LoginScreen extends React.Component {
state = {
email: "",
password: "",
notification: {},
errorMessage: null
};
async componentDidMount() {
this.registerForPushNotificationsAsync();
//Notifications.addNotificationReceivedListener(this._handleNotification);
Notifications2.addListener(data => {
this.setState({ notification: data });
});
Notifications.addNotificationResponseReceivedListener(
this._handleNotificationResponse
);
}
_handleNotification = notification => {
this.setState({ notification: notification });
};
_handleNotificationResponse = response => {
console.log(response);
};
handleLogin = async () => {
try {
const { email, password } = this.state;
const expoPushToken = await Notifications.getExpoPushTokenAsync();
console.log(expoPushToken);
const userinfo = await firebase
.auth()
.signInWithEmailAndPassword(email, password);
console.log("user ID ", userinfo.user.uid);
await firebase
.firestore()
.collection("users")
.doc(userinfo.user.uid.toString())
.update({
expo_token: expoPushToken["data"]
})
.then(function() {
console.log("token successfully updated!");
})
.catch(function(error) {
// The document probably doesn't exist.
console.error("Error updating document: ", error);
});
} catch (error) {
console.log("=======Error in login", error);
this.setState({ errorMessage: error.message });
}
};
registerForPushNotificationsAsync = async () => {
if (Constants.isDevice) {
const { status: existingStatus } = await Permissions.getAsync(
Permissions.NOTIFICATIONS
);
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Permissions.askAsync(
Permissions.NOTIFICATIONS
);
finalStatus = status;
}
if (finalStatus !== "granted") {
alert("Failed to get push token for push notification!");
return;
}
const token = await Notifications.getExpoPushTokenAsync();
console.log(token);
//this.setState({ expoPushToken: token });
} else {
alert("Must use physical device for Push Notifications");
}
if (Platform.OS === "android") {
Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C"
});
}
};
import { Permissions } from "expo-permissions";
should of been
import * as Permissions from 'expo-permissions';
Sometimes we all make simple mistakes.

React rails authentication - Get user state without page reload

I'm building a SPA with rails API on the back-end and React on the front-end. The user authentication works as desired and I'm getting the user state from the rails. The problem is that when a user signs in to the app, I have to manually refresh the whole browser to update the state.
// App.js
function userReducer(state, action) {
switch (action.type) {
case "success": {
return {
...state,
id: action.id,
username: action.username,
email: action.email,
logged_in: action.status,
error: ""
};
}
case "fail": {
return {
...state,
id: "",
username: "",
email: "",
logged_in: false,
error: action.error
};
}
default:
return state;
}
}
// set initial state of the logged in user
const intialUserState = {
id: "",
username: "",
email: "",
logged_in: false,
error: ""
};
export const UserStatus = React.createContext(intialUserState);
function App() {
const [userState, dispatch] = useReducer(userReducer, intialUserState);
// fetch the user's data
function fetchLoggedInUserData() {
axios
.get("http://localhost:3000/api/v1/logged_in", { withCredential: true })
.then(response => {
const { id, username, email, logged_in } = response.data;
if (response.status === 200) {
dispatch({
type: "success",
id: id,
username: username,
email: email,
status: logged_in
});
}
})
.catch(error => {
dispatch({ type: "fail", error: error });
});
}
// when the app loads, check if the user is signed in or not.
// if the user is signed in, then store the user's data into the state
useEffect(() => {
fetchLoggedInUserData();
}, []);
return (
<UserStatus.Provider value={{ userState, dispatch }}>
<Router>
<>
<Switch>
<Route path="/admin" component={Admin} />
<Route path="/" render={props => <Public {...props} />} />
</Switch>
</>
</Router>
</UserStatus.Provider>
);
}
The above code works as expected and I can store the user state as you see from the code. The problem is when a user clicks on the submit button I want to automatically store the user without having to refresh the page. The code below is from Login.js
// Login.js
function signinReducer(state, action) {
switch (action.type) {
case "field": {
return {
...state,
[action.field]: action.value
};
}
case "signin": {
return {
...state,
error: "",
isLoading: true
};
}
case "success": {
return {
...state,
error: "",
isLoading: false
};
}
case "error": {
return {
...state,
isLoading: false,
error: action.error
};
}
}
}
const initialState = {
username: "",
password: "",
isLoading: false,
error: ""
};
function FormMain() {
const [signinState, dispatch] = useReducer(signinReducer, initialState);
const { username, password, isLoading, error } = signinState;
const handleSubmit = async e => {
e.preventDefault();
dispatch({ type: "signin" });
await postUserData();
};
function postUserData() {
axios
.post(
"http://localhost:3000/api/v1/sessions",
{
user: {
username: username,
password: password
}
},
{ withCredentials: true }
)
.then(response => {
if (response.status === 200) {
dispatch({ type: "success" });
}
})
.catch(error => {
// dispatch({ type: "error", error: error.response.data[0] });
});
}
}
I have removed the sign-in form from the code above as it was getting too lengthy.
The solution can be to somehow transfer the state from the Login.js to App.js or directly update the state of the App.js from the Login.js and make use of the useEffect in the App.js, and update the state without having to manually refresh the browser but I do not know how to do that.
The problem is fixed by creating a new case to App.js and updating that case from Login.js.
// App.js
function userReducer(state, action) {
switch (action.type) {
case "success": {
return {
...state,
id: action.id,
username: action.username,
email: action.email,
admin: action.admin,
logged_in: action.status,
error: ""
};
}
case "fail": {
return {
...state,
id: "",
username: "",
email: "",
admin: false,
logged_in: false,
error: action.error
};
}
case "globalFetch": {
return {
...state,
id: action.id,
username: action.username,
email: action.email,
admin: action.admin,
logged_in: action.status,
error: ""
};
}
default:
return state;
}
}
As you can see the globalFetch case can be easily passed down to the Login.js via useContext and then update the case as per your requirement.
// Login.js
function postUserData() {
axios
.post(
"http://localhost:3000/api/v1/sessions",
{
user: {
username: username,
password: password
}
},
{ withCredentials: true }
)
.then(response => {
if (response.status === 200) {
const { id, username, email, logged_in, admin } = response.data;
dispatch({ type: "success" });
state.dispatch({
type: "globalFetch",
id: id,
username: username,
email: email,
admin: admin,
status: logged_in
});
}
})
.catch(error => {
// dispatch({ type: "error", error: error.response.data[0] });
});
}
// More code...
}

don't display notification in ios device when use react-native-fcm

i'm using from react-native-fcm for recieve pushNotification and do all config in this document(https://github.com/evollu/react-native-fcm)
in ios device only recieve notification and call notificationListener that checked by console.log but dont display notification message and alert even test FCM.presentLocalNotification for show local notification still dont show notification
async componentDidMount() {
if (Platform.OS === 'ios') {
try {
const result = await FCM.requestPermissions({ badge: false, sound: true, alert: true });
} catch (e) {
console.error(e);
}
FCM.getFCMToken().then(token => {
if (token !== undefined) {
// this.props.onChangeToken(token);
} else {
console.log('TOKEN (getFCMToken)', 'null');
}
});
// FCM.getAPNSToken().then(token => {
// this.props.onAPNToken(token);
// });
FCM.getInitialNotification().then(notif => {
console.log('INITIAL NOTIFICATION', notif);
});
this.notificationListener = FCM.on(FCMEvent.Notification, async (notif) => {
console.log(" >> notificationListener: ", notif)
if (notif.local_notification) return;
FCM.presentLocalNotification({
body: 'tdtydt',
priority: "high",
title: 'notif.fcm.title',
sound: "default",
show_in_foreground: true,
local_notification: true,
icon: "ic_launcher",
status: "400"
});
});
this.refreshTokenListener = FCM.on(FCMEvent.RefreshToken, token => {
console.log('TOKEN (refreshUnsubscribe)', token);
this.props.onChangeToken(token);
});
FCM.enableDirectChannel();
this.channelConnectionListener = FCM.on(FCMEvent.DirectChannelConnectionChanged, (data) => {
console.log(`direct channel connected${data}`);
});
setTimeout(() => {
FCM.isDirectChannelEstablished().then(d => console.log('isDirectChannelEstablished', d));
}, 500);
}

Can I run `stencil push` command without prompt?

I'm trying to configure bitbucket pipelines with bigcommerce stencil.
The problem is the stencil push command asks some questions. I would like to auto-respond those questions.
Is that possible?
These are the questions prompted:
* Would you like to apply your theme to your store at http://xxxxxxx/? (y/N)
* Which variation would you like to apply?
- Light
- Bold
- Warm
You will need to make changes to the existing stencil-cli to make this work.
Stencil-cli uses the Commander package. My solution was to create an additional flag that would skip all the prompts if you supplied a variant name. This was created from stencil-cli version 1.13.1 so you may need to modify the example.
Inside /bin/stencil-push:
#!/usr/bin/env node
require('colors');
const apiHost = 'https://api.bigcommerce.com';
const dotStencilFilePath = './.stencil';
const options = { dotStencilFilePath };
const pkg = require('../package.json');
const Program = require('commander');
const stencilPush = require('../lib/stencil-push');
const versionCheck = require('../lib/version-check');
Program
.version(pkg.version)
.option('--host [hostname]', 'specify the api host', apiHost)
.option('-f, --file [filename]', 'specify the filename of the bundle to upload')
.option('-a, --activate [variationname]', 'specify the variation of the theme to activate')
.parse(process.argv);
if (!versionCheck()) {
return;
}
stencilPush(Object.assign({}, options, {
apiHost: Program.host || apiHost,
bundleZipPath: Program.file,
activate: Program.activate,
}), (err, result) => {
if (err) {
console.log('not ok'.red + ` -- ${err}`);
console.log('Please try again. If this error persists, please visit https://github.com/bigcommerce/stencil-cli/issues and submit an issue.');
} else {
console.log('ok'.green + ` -- ${result}`);
}
});
Inside /lib/stencil-push.js:
'use strict';
const _ = require('lodash');
const async = require('async');
const Bundle = require('./stencil-bundle');
const fs = require('fs');
const Inquirer = require('inquirer');
const os = require('os');
const ProgressBar = require('progress');
const themeApiClient = require('./theme-api-client');
const themePath = process.cwd();
const themeConfig = require('./theme-config').getInstance(themePath);
const uuid = require('uuid4');
const utils = {};
const Wreck = require('wreck');
const bar = new ProgressBar('Processing [:bar] :percent; ETA: :etas', {
complete: '=',
incomplete: ' ',
total: 100,
});
module.exports = utils;
function validateOptions(options, fields) {
options = options || {};
fields = fields || [];
fields.forEach(field => {
if (!_.has(options, field)) {
throw new Error(`${field} is required!`);
}
});
return options;
}
utils.readStencilConfigFile = (options, callback) => {
options = validateOptions(options, ['dotStencilFilePath']);
fs.readFile(options.dotStencilFilePath, { encoding: 'utf8' }, (err, data) => {
if (err) {
err.name = 'StencilConfigReadError';
return callback(err);
}
callback(null, Object.assign({}, options, {
config: JSON.parse(data),
}));
});
};
utils.getStoreHash = (options, callback) => {
options = validateOptions(options, ['config.normalStoreUrl']);
Wreck.get(`${options.config.normalStoreUrl}/admin/oauth/info`, { json: true, rejectUnauthorized: false }, (error, response, payload) => {
if (error) {
error.name = 'StoreHashReadError';
return callback(error);
}
if (response.statusCode !== 200 || !payload.store_hash) {
const err = new Error('Failed to retrieve store hash');
err.name = 'StoreHashReadError';
return callback(err);
}
callback(null, Object.assign({}, options, { storeHash: payload.store_hash }));
});
};
utils.getThemes = (options, callback) => {
const config = options.config;
themeApiClient.getThemes({
accessToken: config.accessToken,
apiHost: options.apiHost,
clientId: config.clientId,
storeHash: options.storeHash,
}, (error, result) => {
if (error) {
return callback(error);
}
callback(null, Object.assign({}, options, {
themes: result.themes,
}));
});
};
utils.generateBundle = (options, callback) => {
let bundle;
if (options.bundleZipPath) {
return async.nextTick(callback.bind(null, null, options));
}
bundle = new Bundle(themePath, themeConfig, themeConfig.getRawConfig(), {
dest: os.tmpdir(),
name: uuid(),
});
bundle.initBundle((err, bundleZipPath) => {
if (err) {
err.name = 'BundleInitError';
return callback(err);
}
callback(null, Object.assign(options, { bundleZipPath: options.bundleZipPath || bundleZipPath }));
});
};
utils.uploadBundle = (options, callback) => {
const config = options.config;
themeApiClient.postTheme({
accessToken: config.accessToken,
apiHost: options.apiHost,
bundleZipPath: options.bundleZipPath,
clientId: config.clientId,
storeHash: options.storeHash,
}, (error, result) => {
if (error) {
error.name = 'ThemeUploadError';
return callback(error);
}
callback(null, Object.assign({}, options, {
jobId: result.jobId,
themeLimitReached: !!result.themeLimitReached,
}));
});
};
utils.notifyUserOfThemeLimitReachedIfNecessary = (options, callback) => {
if (options.themeLimitReached) {
console.log('warning'.yellow + ` -- You have reached your upload limit. In order to proceed, you'll need to delete at least one theme.`);
}
return async.nextTick(callback.bind(null, null, options));
};
utils.promptUserToDeleteThemesIfNecessary = (options, callback) => {
if (!options.themeLimitReached) {
return async.nextTick(callback.bind(null, null, options));
}
const questions = [{
choices: options.themes.map(theme => ({
disabled: theme.is_active || !theme.is_private,
name: theme.name,
value: theme.uuid,
})),
message: 'Which theme(s) would you like to delete?',
name: 'themeIdsToDelete',
type: 'checkbox',
validate: (val) => {
if (val.length > 0) {
return true;
} else {
return 'You must delete at least one theme';
}
},
}];
Inquirer.prompt(questions, (answers) => {
callback(null, Object.assign({}, options, answers));
});
};
utils.deleteThemesIfNecessary = (options, callback) => {
const config = options.config;
if (!options.themeLimitReached) {
return async.nextTick(callback.bind(null, null, options));
}
async.parallel(options.themeIdsToDelete.map(themeId => {
return cb => {
themeApiClient.deleteThemeById(Object.assign({
accessToken: config.accessToken,
apiHost: options.apiHost,
clientId: config.clientId,
storeHash: options.storeHash,
themeId,
}, options), cb);
}
}), err => {
if (err) {
err.name = 'ThemeDeletionError';
return callback(err);
}
callback(null, options);
})
};
utils.uploadBundleAgainIfNecessary = (options, callback) => {
if (!options.themeLimitReached) {
return async.nextTick(callback.bind(null, null, options));
}
utils.uploadBundle(options, callback);
};
utils.notifyUserOfThemeUploadCompletion = (options, callback) => {
console.log('ok'.green + ' -- Theme Upload Finished');
return async.nextTick(callback.bind(null, null, options));
};
utils.markJobProgressPercentage = percentComplete => {
bar.update(percentComplete / 100);
};
utils.markJobComplete = () => {
utils.markJobProgressPercentage(100);
console.log('ok'.green + ' -- Theme Processing Finished');
};
utils.pollForJobCompletion = () => {
return async.retryable({
interval: 1000,
errorFilter: err => {
if (err.name === "JobCompletionStatusCheckPendingError") {
utils.markJobProgressPercentage(err.message);
return true;
}
return false;
},
times: Number.POSITIVE_INFINITY,
}, utils.checkIfJobIsComplete);
};
utils.checkIfJobIsComplete = (options, callback) => {
const config = options.config;
themeApiClient.getJob({
accessToken: config.accessToken,
apiHost: options.apiHost,
clientId: config.clientId,
storeHash: options.storeHash,
bundleZipPath: options.bundleZipPath,
jobId: options.jobId,
}, (error, result) => {
if (error) {
return callback(error);
}
utils.markJobComplete();
callback(null, Object.assign({}, options, result));
});
};
utils.promptUserWhetherToApplyTheme = (options, callback) => {
if (options.activate) {
callback(null, Object.assign({}, options, { applyTheme: true }));
} else {
const questions = [{
type: 'confirm',
name: 'applyTheme',
message: `Would you like to apply your theme to your store at ${options.config.normalStoreUrl}?`,
default: false,
}];
Inquirer.prompt(questions, answers => {
callback(null, Object.assign({}, options, { applyTheme: answers.applyTheme }));
});
};
};
utils.getVariations = (options, callback) => {
if (!options.applyTheme) {
return async.nextTick(callback.bind(null, null, options));
}
themeApiClient.getVariationsByThemeId({
accessToken: options.accessToken,
apiHost: options.apiHost,
clientId: options.clientId,
themeId: options.themeId,
storeHash: options.storeHash,
}, (error, result) => {
if (error) {
return callback(error);
};
if (options.activate !== true && options.activate !== undefined) {
const findVariation = result.variations.find(item => item.name === options.activate);
callback(null, Object.assign({}, options, { variationId: findVariation.uuid }));
} else if (options.activate === true) {
callback(null, Object.assign({}, options, { variationId: result.variations[0].uuid }));
} else {
callback(null, Object.assign({}, options, result));
};
});
};
utils.promptUserForVariation = (options, callback) => {
if (!options.applyTheme) {
return async.nextTick(callback.bind(null, null, options))
}
if (options.variationId) {
callback(null, options);
} else {
const questions = [{
type: 'list',
name: 'variationId',
message: 'Which variation would you like to apply?',
choices: options.variations.map(variation => ({ name: variation.name, value: variation.uuid })),
}];
Inquirer.prompt(questions, answers => {
console.log(answers);
callback(null, Object.assign({}, options, answers));
});
};
};
utils.requestToApplyVariationWithRetrys = () => {
return async.retryable({
interval: 1000,
errorFilter: err => {
if (err.name === "VariationActivationTimeoutError") {
console.log('warning'.yellow + ` -- Theme Activation Timed Out; Retrying...`);
return true;
}
return false;
},
times: 3,
}, utils.requestToApplyVariation);
};
utils.requestToApplyVariation = (options, callback) => {
if (!options.applyTheme) {
return async.nextTick(callback.bind(null, null, options));
}
themeApiClient.activateThemeByVariationId({
accessToken: options.accessToken,
apiHost: options.apiHost,
clientId: options.clientId,
storeHash: options.storeHash,
variationId: options.variationId,
}, (error, result) => {
if (error) {
return callback(error);
}
callback(null, Object.assign({}, options, result));
});
};
utils.notifyUserOfCompletion = (options, callback) => {
callback(null, 'Stencil Push Finished');
};
This allowed me to use something like stencil push --activate bold to specify a variation and skip all of the prompts.
As of version 1.15.1 this seems to be available with the -a, --activate [variationname] switch for stencil push
> stencil push -a "My Variant" worked for me
Thanks Nikita Puza!
It works like a charm. I applied the changes on stencil 1.14.1 version and the source code looks exactly the same.
The only difference is the second file is called stencil-push.utils.js instead of stencil-push.js

Resources