Skip to content
This repository has been archived by the owner on Jun 30, 2022. It is now read-only.

[WIP-GSoC-19] Store Livechat sessions on backend side #242

Open
wants to merge 17 commits into
base: develop
Choose a base branch
from
Open
10 changes: 6 additions & 4 deletions src/api.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import queryString from 'query-string';
import LivechatClient from '@rocket.chat/sdk/lib/clients/Livechat';

const host = window.SERVER_URL
|| queryString.parse(window.location.search).serverUrl
|| (process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null);
// eslint-disable-next-line import/no-absolute-path
import LivechatClient from '/Users/knrt10/dev/openSource/RocketChat/Rocket.Chat.js.SDK/dist/lib/clients/Livechat';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you forget to remove this code before pushing your changes.


const host = window.SERVER_URL
|| queryString.parse(window.location.search).serverUrl
|| (process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null);
const useSsl = host && host.match(/^https:/) !== null;

export const Livechat = new LivechatClient({ host, protocol: 'ddp', useSsl });
3 changes: 3 additions & 0 deletions src/components/App/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component } from 'preact';
import { Router, route } from 'preact-router';
import queryString from 'query-string';

import { locationUpdate, userSessionPresence } from '../../lib/location';
import history from '../../history';
import Chat from '../../routes/Chat';
import LeaveMessage from '../../routes/LeaveMessage';
Expand Down Expand Up @@ -136,6 +137,7 @@ export class App extends Component {
async initialize() {
// TODO: split these behaviors into composable components
await Connection.init();
locationUpdate();
this.handleTriggers();
CustomFields.init();
Hooks.init();
Expand All @@ -149,6 +151,7 @@ export class App extends Component {
async finalize() {
CustomFields.reset();
userPresence.reset();
userSessionPresence.reset();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this lib.

visibility.removeListener(this.handleVisibilityChange);
I18n.off('change', this.handleLanguageChange);
}
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,4 @@
"your_spot_is_spot_a35cd288": "Seu lugar é #%{spot}",
"your_spot_is_spot_estimated_wait_time_estimatedwai_d0ff46e0": "Seu lugar é #%{spot} (Tempo estimado: %{estimatedWaitTime})"
}
}
}
2 changes: 1 addition & 1 deletion src/i18n/pt_BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,4 @@
"your_spot_is_spot_a35cd288": "Seu lugar é #%{spot}",
"your_spot_is_spot_estimated_wait_time_estimatedwai_d0ff46e0": "Seu lugar é #%{spot} (Tempo estimado: %{estimatedWaitTime})"
}
}
}
2 changes: 1 addition & 1 deletion src/lib/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const Connection = {
if (timer) {
return;
}
timer = setTimeout(async() => {
timer = setTimeout(async () => {
try {
clearTimeout(timer);
timer = false;
Expand Down
290 changes: 290 additions & 0 deletions src/lib/location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/* eslint-disable no-lonely-if */
/* eslint-disable no-alert */
import { getToken } from './main';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clone from the userPresence lib, you don't need a new lib to do the same things, it does not make sense.
You can do what you want by calling the new methods related to session stuff within the userPresence lib.
Please, remove this file and move the new methods to a helper lib.

import { Livechat } from '../api';
import store from '../store';

const docActivityEvents = ['mousemove', 'mousedown', 'touchend', 'keydown'];
const token = getToken();
let timer;
let initiated = false;
const awayTime = 300000;
let self;
let oldStatus;

export const userSessionPresence = {

init() {
if (initiated) {
return;
}

initiated = true;
self = this;
store.on('change', this.handleStoreChange);
},

reset() {
initiated = false;
this.stopEvents();
store.off('change', this.handleStoreChange);
},

stopTimer() {
timer && clearTimeout(timer);
},

startTimer() {
this.stopTimer();
timer = setTimeout(this.setAway, awayTime);
},

handleStoreChange(state) {
if (!initiated) {
return;
}

const { token } = state;
token ? self.startEvents() : self.stopEvents();
},

startEvents() {
docActivityEvents.forEach((event) => {
document.addEventListener(event, this.setOnline);
});

window.addEventListener('focus', this.setOnline);
},

stopEvents() {
docActivityEvents.forEach((event) => {
document.removeEventListener(event, this.setOnline);
});

window.removeEventListener('focus', this.setOnline);
this.stopTimer();
},

async setOnline() {
self.startTimer();
if (oldStatus === 'online') {
return;
}
oldStatus = 'online';

await Livechat.updateSessionStatus('online', token);
},

async setAway() {
self.stopTimer();
if (oldStatus === 'away') {
return;
}
oldStatus = 'away';
await Livechat.updateSessionStatus('away', token);
},
};

const deviceInfo = () => {
const module = {
options: [],
header: [navigator.platform, navigator.userAgent, navigator.appVersion, navigator.vendor, window.opera],
dataos: [
{ name: 'Windows Phone', value: 'Windows Phone', version: 'OS' },
{ name: 'Windows', value: 'Win', version: 'NT' },
{ name: 'iPhone', value: 'iPhone', version: 'OS' },
{ name: 'iPad', value: 'iPad', version: 'OS' },
{ name: 'Kindle', value: 'Silk', version: 'Silk' },
{ name: 'Android', value: 'Android', version: 'Android' },
{ name: 'PlayBook', value: 'PlayBook', version: 'OS' },
{ name: 'BlackBerry', value: 'BlackBerry', version: '/' },
{ name: 'Macintosh', value: 'Mac', version: 'OS X' },
{ name: 'Linux', value: 'Linux', version: 'rv' },
{ name: 'Palm', value: 'Palm', version: 'PalmOS' },
],
databrowser: [
{ name: 'Chrome', value: 'Chrome', version: 'Chrome' },
{ name: 'Firefox', value: 'Firefox', version: 'Firefox' },
{ name: 'Safari', value: 'Safari', version: 'Version' },
{ name: 'Internet Explorer', value: 'MSIE', version: 'MSIE' },
{ name: 'Opera', value: 'Opera', version: 'Opera' },
{ name: 'BlackBerry', value: 'CLDC', version: 'CLDC' },
{ name: 'Mozilla', value: 'Mozilla', version: 'Mozilla' },
],
init() {
const agent = this.header.join(' ');
const os = this.matchItem(agent, this.dataos);
const browser = this.matchItem(agent, this.databrowser);

return { os, browser };
},
matchItem(string, data) {
let i = 0;
let j = 0;
let regex;
let regexv;
let match;
let matches;
let version;

for (i = 0; i < data.length; i += 1) {
regex = new RegExp(data[i].value, 'i');
match = regex.test(string);
if (match) {
regexv = new RegExp(`${ data[i].version }[- /:;]([\\d._]+)`, 'i');
matches = string.match(regexv);
version = '';
if (matches) { if (matches[1]) { matches = matches[1]; } }
if (matches) {
matches = matches.split(/[._]+/);
for (j = 0; j < matches.length; j += 1) {
if (j === 0) {
version += `${ matches[j] }.`;
} else {
version += matches[j];
}
}
} else {
version = '0';
}
return {
name: data[i].name,
version: parseFloat(version),
};
}
}
return { name: 'unknown', version: 0 };
},
};

const info = module.init();
return {
os: info.os.name,
osVersion: info.os.version,
browserName: info.browser.name,
browserVersion: info.browser.version,
};
};

const userDataWithoutLocation = {
token,
deviceInfo: deviceInfo(),
};


/**
* This is used to convert location to a default type we want to send to server
* @param {Object} location
* @returns {Object}
*/
const convertLocationToSend = (location) => (
{
countryName: location.country || location.country_name,
countryCode: location.country_code,
city: location.city || location.state,
latitude: location.latitude,
longitude: location.longitude,
completLocation: `${ location.country }, ${ location.state }, ${ location.city }`,
});

/**
* This is used to get location details for user
* @param {Number} latitude
* @param {Number} longitude
* @returns {Object}
*/
const locationPrimary = async (latitude, longitude) => {
const { address } = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${ latitude }&lon=${ longitude }`, {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is going out to this api to grab location? This has to definitely be optional if done at all. Some serious privacy concerns around sending data like this out.

mode: 'cors',
headers: {
'Access-Control-Allow-Origin': '*',
},
}).then((res) => res.json());

const location = convertLocationToSend(address);
location.latitude = latitude;
location.longitude = longitude;

return {
location,
token,
deviceInfo: deviceInfo(),
};
};

/**
* This is backup method to get location of user
* @returns {Object}
*/
const locationBackup = async () => {
const location = await fetch('https://api.ipdata.co?api-key=test', {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

headers: {
Accept: 'application/json',
},
}).then((res) => res.json());

return {
location: convertLocationToSend(location),
token,
deviceInfo: deviceInfo(),
};
};

/**
* This function works in following way
* 1. Check user location already present or not
* 2. If not, asks for user location access
* 3. If not granted, sets locationAccess in store as false, so that we ask for access again, before starting chat
* 4. If granted, sets location of user info to DB
* 5. If location already present, increases the visit count for user
*/
export const locationUpdate = async () => {
const checkLocationUser = await Livechat.checkLocationUser(token);
// check user location all ready there or not
if (checkLocationUser && !checkLocationUser._id) {
// Ask for permission for location
if (navigator.geolocation) {
store.setState({
locationAccess: true,
});
navigator.geolocation.getCurrentPosition(async (position) => {
const locationUser = await locationPrimary(position.coords.latitude, position.coords.longitude);
await Livechat.sendLocationData(locationUser);
userSessionPresence.init();
}, async (err) => {
// This means user has denied location access
// We need then to confirm location before starting the chat
// Save state of location access inside store.
if (err) {
store.setState({
locationAccess: false,
});
userSessionPresence.init();
// Send user data without location
await Livechat.sendUserDataWithoutLocation(userDataWithoutLocation);
}
});
} else {
// It means navigator is not supported in the browser, so ask
// for location access by backup API.
if (confirm('Please allow to access your location, for better assistance')) {
store.setState({
locationAccess: true,
});
const locationUser = await locationBackup();
await Livechat.sendLocationData(locationUser);
userSessionPresence.init();
} else {
store.setState({
locationAccess: false,
});
userSessionPresence.init();
// Send user data without location
await Livechat.sendUserDataWithoutLocation(userDataWithoutLocation);
}
}
} else {
// Update visit count for user
Livechat.updateVisitCount(token);
}
};
6 changes: 5 additions & 1 deletion src/lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const loadConfig = async () => {
});
};

export const getToken = () => {
const { token } = store.state;
return token;
};

export const processUnread = async () => {
const { minimized, visible, messages } = store.state;
if (minimized || !visible) {
Expand All @@ -61,4 +66,3 @@ export const processUnread = async () => {
await store.setState({ unread: unreadMessages.length });
}
};

2 changes: 2 additions & 0 deletions src/routes/Chat/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,15 @@ export class ChatContainer extends Component {
this.stopTypingDebounced.stop();
await Promise.all([
this.stopTyping({ rid, username: user.username }),
Livechat.updateSessionStatus('online', token),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why call this method here?
We have a specific lib to deal with user status.

Livechat.sendMessage({ msg, token, rid }),
]);
} catch (error) {
const { data: { error: reason } } = error;
const alert = { id: createToken(), children: reason, error: true, timeout: 5000 };
await dispatch({ alerts: (alerts.push(alert), alerts) });
}

await Livechat.notifyVisitorTyping(rid, user.username, false);
}

Expand Down
1 change: 1 addition & 0 deletions src/routes/Register/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class RegisterContainer extends Component {
await dispatch({ loading: true, department });
try {
await Livechat.grantVisitor({ visitor: { ...fields, token } });
await Livechat.updateVisitorSessionOnRegister({ visitor: { ...fields, token } });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just one method to update the livechat session?
You just need one method, you can pass specific data as parameters.

parentCall('callback', ['pre-chat-form-submit', fields]);
await loadConfig();
} finally {
Expand Down
Loading