From ec80ff2e06f10e4c122049d7416c2889b277f1ab Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Wed, 12 May 2021 02:41:42 +0530 Subject: [PATCH 01/11] new: added sync indicator --- assets/images/sync.svg | 4 + src/components/AvatarWithIndicator.js | 133 ++++++++++++++++++++----- src/components/Icon/Expensicons.js | 2 + src/components/Icon/index.js | 4 +- src/pages/home/sidebar/SidebarLinks.js | 8 ++ src/styles/styles.js | 29 +++--- 6 files changed, 141 insertions(+), 39 deletions(-) create mode 100644 assets/images/sync.svg diff --git a/assets/images/sync.svg b/assets/images/sync.svg new file mode 100644 index 000000000000..e4486116b970 --- /dev/null +++ b/assets/images/sync.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 87b00a1c347d..813111382aac 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -1,8 +1,13 @@ -import React, {memo} from 'react'; -import {View, StyleSheet} from 'react-native'; +import React, {PureComponent} from 'react'; +import { + View, StyleSheet, Animated, Easing, +} from 'react-native'; import PropTypes from 'prop-types'; import Avatar from './Avatar'; +import themeColors from '../styles/themes/default'; import styles from '../styles/styles'; +import Icon from './Icon'; +import {Sync} from './Icon/Expensicons'; const propTypes = { // Is user active? @@ -14,37 +19,115 @@ const propTypes = { // avatar size size: PropTypes.string, + // Whether true, shows sync indicator + isSyncing: PropTypes.bool, }; const defaultProps = { isActive: false, size: 'default', + isSyncing: false, }; -const AvatarWithIndicator = ({ - isActive, - source, - size, -}) => { - const indicatorStyles = [ - size === 'large' ? styles.statusIndicatorLarge : styles.statusIndicator, - isActive ? styles.statusIndicatorOnline : styles.statusIndicatorOffline, - ]; - - return ( - - - - - ); -}; +class AvatarWithIndicator extends PureComponent { + constructor(props) { + super(props); + + this.rotate = new Animated.Value(0); + this.scale = new Animated.Value(1); + this.startSyncIndicator = this.startSyncIndicator.bind(this); + this.stopSyncIndicator = this.stopSyncIndicator.bind(this); + } + + componentDidMount() { + if (this.props.isSyncing) { + this.startSyncIndicator(); + } + } + + componentDidUpdate(prevProps) { + if (!prevProps.isSyncing && this.props.isSyncing) { + this.startSyncIndicator(); + } else if (prevProps.isSyncing && !this.props.isSyncing) { + this.stopSyncIndicator(); + } + } + + componentWillUnmount() { + this.stopSyncIndicator(); + } + + startSyncIndicator() { + Animated.loop(Animated.timing(this.rotate, { + toValue: 1, + duration: 3000, + easing: Easing.linear, + isInteraction: false, + })).start(); + Animated.spring(this.scale, { + toValue: 1.35, + tension: 1, + useNativeDriver: true, + isInteraction: false, + }).start(); + } + + stopSyncIndicator() { + Animated.spring(this.scale, { + toValue: 1, + tension: 1, + useNativeDriver: true, + isInteraction: false, + }).start(() => { + this.rotate.resetAnimation(); + this.scale.resetAnimation(); + this.rotate.setValue(0); + }); + } + + render() { + const indicatorStyles = [ + styles.alignItemsCenter, + styles.justifyContentCenter, + { + transform: [{ + rotate: this.rotate.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '-360deg'], + }), + }, { + scale: this.scale, + }], + }, + this.props.isSyncing ? styles.statusIndicatorSyncing : null, + this.props.size === 'large' ? styles.statusIndicatorLarge : styles.statusIndicator, + this.props.isActive ? styles.statusIndicatorOnline : styles.statusIndicatorOffline, + ]; + + return ( + + + + {this.props.isSyncing && ( + + )} + + + ); + } +} AvatarWithIndicator.defaultProps = defaultProps; AvatarWithIndicator.propTypes = propTypes; AvatarWithIndicator.displayName = 'AvatarWithIndicator'; -export default memo(AvatarWithIndicator); +export default AvatarWithIndicator; diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index e46fef910b2a..4cd0e1aadf80 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -30,6 +30,7 @@ import Camera from '../../../assets/images/camera.svg'; import Gallery from '../../../assets/images/gallery.svg'; import Offline from '../../../assets/images/offline.svg'; import SignOut from '../../../assets/images/sign-out.svg'; +import Sync from '../../../assets/images/sync.svg'; export { ArrowRight, @@ -64,4 +65,5 @@ export { Users, Wallet, SignOut, + Sync, }; diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index a43d73e69bf4..7f7f471eda04 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -8,10 +8,10 @@ const propTypes = { src: PropTypes.func.isRequired, // The width of the icon. - width: PropTypes.number, + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // The height of the icon. - height: PropTypes.number, + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // The fill color for the icon. // Can be provided in hex, rgb, rgba, or as a valid react-native named color such as 'red' or 'blue'. diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index f31c134ed507..0744145bcf2d 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -71,6 +71,9 @@ const propTypes = { // Whether we have the necessary report data to load the sidebar initialReportDataLoaded: PropTypes.bool, + + // Syncing App Data + isSyncingData: PropTypes.bool, }; const defaultProps = { @@ -84,6 +87,7 @@ const defaultProps = { currentlyViewedReportID: '', priorityMode: CONST.PRIORITY_MODE.DEFAULT, initialReportDataLoaded: false, + isSyncingData: false, }; class SidebarLinks extends React.Component { @@ -143,6 +147,7 @@ class SidebarLinks extends React.Component { @@ -200,5 +205,8 @@ export default compose( initialReportDataLoaded: { key: ONYXKEYS.INITIAL_REPORT_DATA_LOADED, }, + isSyncingData: { + key: ONYXKEYS.IS_LOADING_AFTER_RECONNECT, + }, }), )(SidebarLinks); diff --git a/src/styles/styles.js b/src/styles/styles.js index b29f98292853..5f1422f9fb99 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -457,6 +457,23 @@ const styles = { width: 12, zIndex: 10, }, + + statusIndicatorLarge: { + borderColor: themeColors.componentBG, + borderRadius: 8, + borderWidth: 2, + position: 'absolute', + right: 4, + bottom: 4, + height: 16, + width: 16, + zIndex: 10, + }, + + statusIndicatorSyncing: { + padding: 1, + }, + statusIndicatorOnline: { backgroundColor: themeColors.online, }, @@ -1175,18 +1192,6 @@ const styles = { height: 80, }, - statusIndicatorLarge: { - borderColor: themeColors.componentBG, - borderRadius: 8, - borderWidth: 2, - position: 'absolute', - right: 4, - bottom: 4, - height: 16, - width: 16, - zIndex: 10, - }, - displayName: { fontSize: variables.fontSizeLarge, fontFamily: fontFamily.GTA_BOLD, From e37ee152ff87a5aab92a4e6ed81f72e3e4f906b1 Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Wed, 12 May 2021 03:13:03 +0530 Subject: [PATCH 02/11] animation on native --- src/components/AvatarWithIndicator.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 813111382aac..62f8fb3b671d 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -63,12 +63,13 @@ class AvatarWithIndicator extends PureComponent { duration: 3000, easing: Easing.linear, isInteraction: false, + useNativeDriver: true, })).start(); Animated.spring(this.scale, { toValue: 1.35, tension: 1, - useNativeDriver: true, isInteraction: false, + useNativeDriver: true, }).start(); } @@ -76,8 +77,8 @@ class AvatarWithIndicator extends PureComponent { Animated.spring(this.scale, { toValue: 1, tension: 1, - useNativeDriver: true, isInteraction: false, + useNativeDriver: true, }).start(() => { this.rotate.resetAnimation(); this.scale.resetAnimation(); From ae3858c9b624a85443bb7524407ee68f91a71dce Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Wed, 12 May 2021 03:24:17 +0530 Subject: [PATCH 03/11] typo --- src/components/AvatarWithIndicator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 62f8fb3b671d..293e12ad84d7 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -19,7 +19,7 @@ const propTypes = { // avatar size size: PropTypes.string, - // Whether true, shows sync indicator + // When true, shows sync indicator isSyncing: PropTypes.bool, }; From a3baacd163cf9e9b90b2e3baa1ccf1c76086c816 Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Fri, 14 May 2021 05:03:10 +0530 Subject: [PATCH 04/11] Update src/pages/home/sidebar/SidebarLinks.js Co-authored-by: Amal Nazeem --- src/pages/home/sidebar/SidebarLinks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 0744145bcf2d..b6f339228e25 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -72,7 +72,7 @@ const propTypes = { // Whether we have the necessary report data to load the sidebar initialReportDataLoaded: PropTypes.bool, - // Syncing App Data + // Whether we are syncing app data isSyncingData: PropTypes.bool, }; From 78a084b7e7d4337420c51b5312a05144ae6c6a86 Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Fri, 14 May 2021 05:03:29 +0530 Subject: [PATCH 05/11] Update src/components/AvatarWithIndicator.js Co-authored-by: Amal Nazeem --- src/components/AvatarWithIndicator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 293e12ad84d7..daa6957d65d4 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -19,7 +19,7 @@ const propTypes = { // avatar size size: PropTypes.string, - // When true, shows sync indicator + // Whether we show the sync indicator isSyncing: PropTypes.bool, }; From fa3ae71873ed51f3842820530176cef1d3db6cfb Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Sat, 15 May 2021 02:14:46 +0530 Subject: [PATCH 06/11] fix: animation issue with indicator --- src/components/AvatarWithIndicator.js | 50 ++++++++++++++-------- src/styles/getAvatarWithIndicatorStyles.js | 23 ++++++++++ src/styles/styles.js | 5 ++- 3 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 src/styles/getAvatarWithIndicatorStyles.js diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 293e12ad84d7..1b45465c4418 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -8,6 +8,7 @@ import themeColors from '../styles/themes/default'; import styles from '../styles/styles'; import Icon from './Icon'; import {Sync} from './Icon/Expensicons'; +import {getSyncingStyles} from '../styles/getAvatarWithIndicatorStyles'; const propTypes = { // Is user active? @@ -35,6 +36,7 @@ class AvatarWithIndicator extends PureComponent { this.rotate = new Animated.Value(0); this.scale = new Animated.Value(1); + this.startRotation = this.startRotation.bind(this); this.startSyncIndicator = this.startSyncIndicator.bind(this); this.stopSyncIndicator = this.stopSyncIndicator.bind(this); } @@ -57,22 +59,46 @@ class AvatarWithIndicator extends PureComponent { this.stopSyncIndicator(); } - startSyncIndicator() { - Animated.loop(Animated.timing(this.rotate, { + /** + * We need to manually loop the animations as `useNativeDriver` does not work well with Animated.loop. + * + * @memberof AvatarWithIndicator + */ + startRotation() { + this.rotate.setValue(0); + Animated.timing(this.rotate, { toValue: 1, duration: 3000, easing: Easing.linear, isInteraction: false, useNativeDriver: true, - })).start(); + }).start(({finished}) => { + if (finished) { + this.startRotation(); + } + }); + } + + /** + * Start Animation for Indicator + * + * @memberof AvatarWithIndicator + */ + startSyncIndicator() { + this.startRotation(); Animated.spring(this.scale, { - toValue: 1.35, + toValue: 1.666, tension: 1, isInteraction: false, useNativeDriver: true, }).start(); } + /** + * Stop Animation for Indicator + * + * @memberof AvatarWithIndicator + */ stopSyncIndicator() { Animated.spring(this.scale, { toValue: 1, @@ -90,19 +116,9 @@ class AvatarWithIndicator extends PureComponent { const indicatorStyles = [ styles.alignItemsCenter, styles.justifyContentCenter, - { - transform: [{ - rotate: this.rotate.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '-360deg'], - }), - }, { - scale: this.scale, - }], - }, - this.props.isSyncing ? styles.statusIndicatorSyncing : null, this.props.size === 'large' ? styles.statusIndicatorLarge : styles.statusIndicator, this.props.isActive ? styles.statusIndicatorOnline : styles.statusIndicatorOffline, + getSyncingStyles(this.rotate, this.scale), ]; return ( @@ -118,8 +134,8 @@ class AvatarWithIndicator extends PureComponent { )} diff --git a/src/styles/getAvatarWithIndicatorStyles.js b/src/styles/getAvatarWithIndicatorStyles.js new file mode 100644 index 000000000000..7b9a97568b15 --- /dev/null +++ b/src/styles/getAvatarWithIndicatorStyles.js @@ -0,0 +1,23 @@ +/** + * Get Indicator Styles while animating + * + * @param {Object} rotate + * @param {Object} scale + * @returns {Object} + */ +function getSyncingStyles(rotate, scale) { + return { + transform: [{ + rotate: rotate.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '-360deg'], + }), + }, + { + scale, + }], + }; +} + +// eslint-disable-next-line import/prefer-default-export +export {getSyncingStyles}; diff --git a/src/styles/styles.js b/src/styles/styles.js index 0a9224e36936..16c0bb704e32 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -471,7 +471,10 @@ const styles = { }, statusIndicatorSyncing: { - padding: 1, + // padding: 1, + // borderRadius: 8, + // right: -2, + // bottom: -2, }, statusIndicatorOnline: { From 293375bc7d0bc80151065f4a2416b9353461fdbe Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Mon, 17 May 2021 02:41:23 +0530 Subject: [PATCH 07/11] fix: sync icon is visible after user is offline --- src/pages/home/sidebar/SidebarLinks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index b9aca6fd4127..30175cf49d98 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -147,7 +147,7 @@ class SidebarLinks extends React.Component { From 8aca3f289a94218ea0c0f0fc0a24749b15fc87bf Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Wed, 19 May 2021 02:31:06 +0530 Subject: [PATCH 08/11] chore: animation timing --- src/components/AvatarWithIndicator.js | 2 +- src/styles/styles.js | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index dcb5b8a72143..50cda0e56bb6 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -68,7 +68,7 @@ class AvatarWithIndicator extends PureComponent { this.rotate.setValue(0); Animated.timing(this.rotate, { toValue: 1, - duration: 3000, + duration: 2000, easing: Easing.linear, isInteraction: false, useNativeDriver: true, diff --git a/src/styles/styles.js b/src/styles/styles.js index 16c0bb704e32..30de1fa7d443 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -470,13 +470,6 @@ const styles = { zIndex: 10, }, - statusIndicatorSyncing: { - // padding: 1, - // borderRadius: 8, - // right: -2, - // bottom: -2, - }, - statusIndicatorOnline: { backgroundColor: themeColors.online, }, From 9b952b63fa4eb350e806008a24f43dfe374484bb Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Fri, 21 May 2021 08:32:09 -1000 Subject: [PATCH 09/11] add session message --- src/libs/actions/Session.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/actions/Session.js b/src/libs/actions/Session.js index 51c0fe50064d..cead4223d33f 100644 --- a/src/libs/actions/Session.js +++ b/src/libs/actions/Session.js @@ -132,6 +132,9 @@ function fetchAccountDetails(login) { } Onyx.merge(ONYXKEYS.ACCOUNT, {error: response.message}); }) + .catch(() => { + Onyx.merge(ONYXKEYS.ACCOUNT, {error: 'Looks like you\'re not connected to internet. Can you check your connection and try again?'}); + }) .finally(() => { Onyx.merge(ONYXKEYS.ACCOUNT, {loading: false}); }); From 655ba416d552d5c88e57977a6e0165fb7eb7ab58 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Fri, 21 May 2021 08:42:43 -1000 Subject: [PATCH 10/11] Fix offline message --- src/languages/en.js | 3 +++ src/libs/actions/Session.js | 3 ++- src/libs/translate.js | 8 ++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/languages/en.js b/src/languages/en.js index 23206b2960cd..3be6a4e6f83d 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -263,4 +263,7 @@ export default { noPhoneNumber: 'Please enter a phone number including the country code e.g +447814266907', maxParticipantsReached: 'You\'ve reached the maximum number of participants for a group chat.', }, + session: { + offlineMessage: 'Looks like you\'re not connected to internet. Can you check your connection and try again?', + }, }; diff --git a/src/libs/actions/Session.js b/src/libs/actions/Session.js index cead4223d33f..afceefe5a092 100644 --- a/src/libs/actions/Session.js +++ b/src/libs/actions/Session.js @@ -8,6 +8,7 @@ import CONFIG from '../../CONFIG'; import PushNotification from '../Notification/PushNotification'; import Timing from './Timing'; import CONST from '../../CONST'; +import {translate} from '../translate'; let credentials = {}; Onyx.connect({ @@ -133,7 +134,7 @@ function fetchAccountDetails(login) { Onyx.merge(ONYXKEYS.ACCOUNT, {error: response.message}); }) .catch(() => { - Onyx.merge(ONYXKEYS.ACCOUNT, {error: 'Looks like you\'re not connected to internet. Can you check your connection and try again?'}); + Onyx.merge(ONYXKEYS.ACCOUNT, {error: translate('', 'session.offlineMessage')}); }) .finally(() => { Onyx.merge(ONYXKEYS.ACCOUNT, {loading: false}); diff --git a/src/libs/translate.js b/src/libs/translate.js index 64ad70c18d2b..7560fdc15626 100644 --- a/src/libs/translate.js +++ b/src/libs/translate.js @@ -7,12 +7,12 @@ import translations from '../languages/translations'; /** * Return translated string for given locale and phrase * - * @param {String} locale eg 'en', 'es-ES' + * @param {String} [locale] eg 'en', 'es-ES' * @param {String|Array} phrase - * @param {Object} variables - * @returns {string} + * @param {Object} [variables] + * @returns {String} */ -function translate(locale, phrase, variables = {}) { +function translate(locale = 'en', phrase, variables = {}) { const localeLanguage = locale.substring(0, 2); const fullLocale = lodashGet(translations, locale, {}); const language = lodashGet(translations, localeLanguage, {}); From 7bb075c4eb13348b8af5a1414f6be17f3a5d3f42 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Sat, 22 May 2021 00:36:21 +0000 Subject: [PATCH 11/11] Update version to 1.0.51-1 --- android/app/build.gradle | 4 ++-- ios/ExpensifyCash/Info.plist | 2 +- ios/ExpensifyCashTests/Info.plist | 2 +- package-lock.json | 2 +- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2a32c3d53d19..b5037fb210c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,8 +148,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001005100 - versionName "1.0.51-0" + versionCode 1001005101 + versionName "1.0.51-1" } splits { abi { diff --git a/ios/ExpensifyCash/Info.plist b/ios/ExpensifyCash/Info.plist index 1d24a5cc7e9f..dac252149391 100644 --- a/ios/ExpensifyCash/Info.plist +++ b/ios/ExpensifyCash/Info.plist @@ -30,7 +30,7 @@ CFBundleVersion - 1.0.51.0 + 1.0.51.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/ExpensifyCashTests/Info.plist b/ios/ExpensifyCashTests/Info.plist index 769ac9f9c60b..1bf942581c64 100644 --- a/ios/ExpensifyCashTests/Info.plist +++ b/ios/ExpensifyCashTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.0.51.0 + 1.0.51.1 diff --git a/package-lock.json b/package-lock.json index eff1d22824a2..0df857f0aafb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.51-0", + "version": "1.0.51-1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 7fe8a2e37bac..fd926d9d1243 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.51-0", + "version": "1.0.51-1", "author": "Expensify, Inc.", "homepage": "https://expensify.cash", "description": "Expensify.cash is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",