From 790b18d4a46ff3dbbeb3f436822420bf39c367c6 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 18 Jul 2023 12:40:14 -0700 Subject: [PATCH] Privacy mode (#1272) --- .../src/components/PrivacyFilter.tsx | 143 ++++++++++++ .../desktop-client/src/components/Titlebar.js | 68 ++++-- .../src/components/accounts/Balance.js | 8 +- .../components/budget/MobileBudgetTable.js | 156 ++++++------- .../budget/report/BudgetSummary.tsx | 5 +- .../components/budget/report/components.tsx | 6 + .../budget/rollover/BudgetSummary.tsx | 206 ++++++++--------- .../budget/rollover/rollover-components.tsx | 11 + .../src/components/modals/BudgetSummary.js | 41 ++-- .../src/components/settings/Experimental.js | 13 ++ .../src/components/spreadsheet/CellValue.tsx | 69 ++++-- ...amespaceContext.js => NamespaceContext.ts} | 0 .../src/components/spreadsheet/SheetValue.tsx | 84 ------- .../spreadsheet/{format.js => format.ts} | 2 +- .../src/components/spreadsheet/index.ts | 1 + .../components/spreadsheet/useSheetName.ts | 49 ++++ .../components/spreadsheet/useSheetValue.js | 73 ------ .../components/spreadsheet/useSheetValue.ts | 49 ++++ .../desktop-client/src/components/table.tsx | 210 +++++++++++------- .../transactions/TransactionsTable.js | 7 + .../transactions/TransactionsTable.test.js | 43 ++-- .../src/hooks/useFeatureFlag.ts | 1 + packages/desktop-client/src/icons/v2/Eye.js | 55 +++++ .../desktop-client/src/icons/v2/EyeSlashed.js | 17 ++ .../src/icons/v2/eye-slashed.svg | 6 + packages/desktop-client/src/icons/v2/eye.svg | 8 + packages/loot-core/src/client/privacy.ts | 5 + .../src/client/state-types/prefs.d.ts | 6 +- upcoming-release-notes/1272.md | 6 + 29 files changed, 838 insertions(+), 510 deletions(-) create mode 100644 packages/desktop-client/src/components/PrivacyFilter.tsx rename packages/desktop-client/src/components/spreadsheet/{NamespaceContext.js => NamespaceContext.ts} (100%) delete mode 100644 packages/desktop-client/src/components/spreadsheet/SheetValue.tsx rename packages/desktop-client/src/components/spreadsheet/{format.js => format.ts} (94%) create mode 100644 packages/desktop-client/src/components/spreadsheet/index.ts create mode 100644 packages/desktop-client/src/components/spreadsheet/useSheetName.ts delete mode 100644 packages/desktop-client/src/components/spreadsheet/useSheetValue.js create mode 100644 packages/desktop-client/src/components/spreadsheet/useSheetValue.ts create mode 100644 packages/desktop-client/src/icons/v2/Eye.js create mode 100644 packages/desktop-client/src/icons/v2/EyeSlashed.js create mode 100644 packages/desktop-client/src/icons/v2/eye-slashed.svg create mode 100644 packages/desktop-client/src/icons/v2/eye.svg create mode 100644 packages/loot-core/src/client/privacy.ts create mode 100644 upcoming-release-notes/1272.md diff --git a/packages/desktop-client/src/components/PrivacyFilter.tsx b/packages/desktop-client/src/components/PrivacyFilter.tsx new file mode 100644 index 00000000000..e9dd9e5cb10 --- /dev/null +++ b/packages/desktop-client/src/components/PrivacyFilter.tsx @@ -0,0 +1,143 @@ +import React, { + useState, + useCallback, + Children, + type ComponentPropsWithRef, + type ReactNode, +} from 'react'; + +import usePrivacyMode from 'loot-core/src/client/privacy'; + +import useFeatureFlag from '../hooks/useFeatureFlag'; +import { useResponsive } from '../ResponsiveProvider'; + +import { View } from './common'; + +export type ConditionalPrivacyFilterProps = { + children: ReactNode; + privacyFilter?: boolean | PrivacyFilterProps; + defaultPrivacyFilterProps?: PrivacyFilterProps; +}; +export function ConditionalPrivacyFilter({ + children, + privacyFilter, + defaultPrivacyFilterProps, +}: ConditionalPrivacyFilterProps) { + let renderPrivacyFilter = (children, mergedProps) => ( + {children} + ); + return privacyFilter ? ( + typeof privacyFilter === 'boolean' ? ( + {children} + ) : ( + renderPrivacyFilter( + children, + mergeConditionalPrivacyFilterProps( + defaultPrivacyFilterProps, + privacyFilter, + ), + ) + ) + ) : ( + <>{Children.toArray(children)} + ); +} + +type PrivacyFilterProps = ComponentPropsWithRef & { + activationFilters?: (boolean | (() => boolean))[]; + blurIntensity?: number; +}; +export default function PrivacyFilter({ + activationFilters, + blurIntensity, + children, + ...props +}: PrivacyFilterProps) { + let privacyModeFeatureFlag = useFeatureFlag('privacyMode'); + let privacyMode = usePrivacyMode(); + // Limit mobile support for now. + let { isNarrowWidth } = useResponsive(); + let activate = + privacyMode && + !isNarrowWidth && + (!activationFilters || + activationFilters.every(value => + typeof value === 'boolean' ? value : value(), + )); + + let blurAmount = blurIntensity != null ? `${blurIntensity}px` : '3px'; + + return !privacyModeFeatureFlag || !activate ? ( + <>{Children.toArray(children)} + ) : ( + + {children} + + ); +} + +function BlurredOverlay({ blurIntensity, children, ...props }) { + let [hovered, setHovered] = useState(false); + let onHover = useCallback(() => setHovered(true), [setHovered]); + let onHoverEnd = useCallback(() => setHovered(false), [setHovered]); + + let blurStyle = { + ...(!hovered && { + filter: `blur(${blurIntensity})`, + WebkitFilter: `blur(${blurIntensity})`, + }), + }; + + let { style, ...restProps } = props; + + return ( + + {children} + + ); +} +export function mergeConditionalPrivacyFilterProps( + defaultPrivacyFilterProps: PrivacyFilterProps = {}, + privacyFilter: ConditionalPrivacyFilterProps['privacyFilter'], +): ConditionalPrivacyFilterProps['privacyFilter'] { + if (privacyFilter == null || privacyFilter === false) { + return privacyFilter; + } + + if (privacyFilter === true) { + return defaultPrivacyFilterProps; + } + + return merge(defaultPrivacyFilterProps, privacyFilter); +} + +function merge(initialValue, ...objects) { + return objects.reduce((prev, current) => { + Object.keys(current).forEach(key => { + const pValue = prev[key]; + const cValue = current[key]; + + if (Array.isArray(pValue) && Array.isArray(cValue)) { + prev[key] = pValue.concat(...cValue); + } else if (isObject(pValue) && isObject(cValue)) { + prev[key] = merge(pValue, cValue); + } else { + prev[key] = cValue; + } + }); + return prev; + }, initialValue); +} + +function isObject(obj) { + return obj && typeof obj === 'object'; +} diff --git a/packages/desktop-client/src/components/Titlebar.js b/packages/desktop-client/src/components/Titlebar.js index 27e05964281..50eaa6245bc 100644 --- a/packages/desktop-client/src/components/Titlebar.js +++ b/packages/desktop-client/src/components/Titlebar.js @@ -18,6 +18,8 @@ import { listen } from 'loot-core/src/platform/client/fetch'; import useFeatureFlag from '../hooks/useFeatureFlag'; import ArrowLeft from '../icons/v1/ArrowLeft'; import AlertTriangle from '../icons/v2/AlertTriangle'; +import SvgEye from '../icons/v2/Eye'; +import SvgEyeSlashed from '../icons/v2/EyeSlashed'; import NavigationMenu from '../icons/v2/NavigationMenu'; import { useResponsive } from '../ResponsiveProvider'; import { colors } from '../style'; @@ -39,7 +41,7 @@ import { import { useSidebar } from './FloatableSidebar'; import LoggedInUser from './LoggedInUser'; import { useServerURL } from './ServerContext'; -import SheetValue from './spreadsheet/SheetValue'; +import useSheetValue from './spreadsheet/useSheetValue'; export let TitlebarContext = createContext(); @@ -65,23 +67,43 @@ export function TitlebarProvider({ children }) { } function UncategorizedButton() { + let count = useSheetValue(queries.uncategorizedCount()); return ( - - {node => { - const num = node.value; - return ( - num !== 0 && ( - - {num} uncategorized {num === 1 ? 'transaction' : 'transactions'} - - ) - ); - }} - + count !== 0 && ( + + {count} uncategorized {count === 1 ? 'transaction' : 'transactions'} + + ) + ); +} + +function PrivacyButton({ localPrefs, onTogglePrivacy }) { + let [isPrivacyEnabled, setIsPrivacyEnabled] = useState( + localPrefs.isPrivacyEnabled, + ); + let togglePrivacy = () => { + setIsPrivacyEnabled(!isPrivacyEnabled); + onTogglePrivacy(!isPrivacyEnabled); + }; + + let privacyIconStyle = { + width: 23, + height: 23, + color: 'inherit', + }; + + return ( + ); } @@ -257,6 +279,7 @@ function BudgetTitlebar({ globalPrefs, saveGlobalPrefs, localPrefs }) { function Titlebar({ globalPrefs, saveGlobalPrefs, + savePrefs, localPrefs, userData, floatingSidebar, @@ -271,6 +294,11 @@ function Titlebar({ let { isNarrowWidth } = useResponsive(); const serverURL = useServerURL(); + let privacyModeFeatureFlag = useFeatureFlag('privacyMode'); + let onTogglePrivacy = enabled => { + savePrefs({ isPrivacyEnabled: enabled }); + }; + return isNarrowWidth ? null : ( + {privacyModeFeatureFlag && ( + + )} {serverURL ? ( {name}{' '} - {format(balance, 'financial')} + + {format(balance, 'financial')} + ); } @@ -134,6 +137,9 @@ export function Balances({ getStyle={value => ({ color: value < 0 ? colors.r5 : value > 0 ? colors.g5 : colors.n8, })} + privacyFilter={{ + blurIntensity: 5, + }} /> - {({ value: amount }) => { - return ( - - ); - }} - + ); } @@ -99,64 +93,58 @@ function Saved({ projected }) { ); } -class BudgetCell extends PureComponent { - render() { - const { - name, - binding, - editing, - style, - textStyle, - categoryId, - month, - onBudgetAction, - } = this.props; +const BudgetCell = memo(function BudgetCell(props) { + const { + name, + binding, + editing, + style, + textStyle, + categoryId, + month, + onBudgetAction, + } = props; - return ( - - {node => { - return ( - - {}} // temporarily disabled for read-only view - onBlur={value => { - onBudgetAction(month, 'budget-amount', { - category: categoryId, - amount: amountToInteger(value), - }); - }} - /> + let sheetValue = useSheetValue(binding); - - - {format(node.value || 0, 'financial')} - - - - ); + return ( + + - ); - } -} + focused={editing} + textStyle={[styles.smallText, textStyle]} + onChange={() => {}} // temporarily disabled for read-only view + onBlur={value => { + onBudgetAction(month, 'budget-amount', { + category: categoryId, + amount: amountToInteger(value), + }); + }} + /> + + + + {format(sheetValue || 0, 'financial')} + + + + ); +}); // eslint-disable-next-line @typescript-eslint/no-unused-vars function BudgetGroupPreview({ group, pending, style }) { diff --git a/packages/desktop-client/src/components/budget/report/BudgetSummary.tsx b/packages/desktop-client/src/components/budget/report/BudgetSummary.tsx index b7542b8922c..d99386bfac3 100644 --- a/packages/desktop-client/src/components/budget/report/BudgetSummary.tsx +++ b/packages/desktop-client/src/components/budget/report/BudgetSummary.tsx @@ -27,6 +27,7 @@ import { AlignedText, } from '../../common'; import NotesButton from '../../NotesButton'; +import PrivacyFilter from '../../PrivacyFilter'; import CellValue from '../../spreadsheet/CellValue'; import format from '../../spreadsheet/format'; import NamespaceContext from '../../spreadsheet/NamespaceContext'; @@ -281,7 +282,9 @@ function Saved({ projected, style }: SavedProps) { }, ])} > - {format(saved, 'financial')} + + {format(saved, 'financial')} + diff --git a/packages/desktop-client/src/components/budget/report/components.tsx b/packages/desktop-client/src/components/budget/report/components.tsx index f93467cb478..484d640136a 100644 --- a/packages/desktop-client/src/components/budget/report/components.tsx +++ b/packages/desktop-client/src/components/budget/report/components.tsx @@ -123,6 +123,11 @@ export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) { valueProps={{ binding: reportBudget.groupBalance(id), type: 'financial', + privacyFilter: { + style: { + paddingRight: MONTH_RIGHT_PADDING, + }, + }, }} /> )} @@ -196,6 +201,7 @@ export const CategoryMonth = memo(function CategoryMonth({ onEdit(category.id, monthIndex)} diff --git a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx index 69bdc3e7712..ce0e1ccc8c4 100644 --- a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx @@ -19,10 +19,12 @@ import { AlignedText, } from '../../common'; import NotesButton from '../../NotesButton'; +import PrivacyFilter from '../../PrivacyFilter'; import CellValue from '../../spreadsheet/CellValue'; import format from '../../spreadsheet/format'; import NamespaceContext from '../../spreadsheet/NamespaceContext'; -import SheetValue from '../../spreadsheet/SheetValue'; +import useSheetName from '../../spreadsheet/useSheetName'; +import useSheetValue from '../../spreadsheet/useSheetValue'; import { MONTH_BOX_SHADOW } from '../constants'; import HoldTooltip from './HoldTooltip'; @@ -76,6 +78,7 @@ function TotalsList({ prevMonthName, collapsed }: TotalsListProps) { } /> @@ -85,6 +88,7 @@ function TotalsList({ prevMonthName, collapsed }: TotalsListProps) { } /> @@ -100,6 +104,7 @@ function TotalsList({ prevMonthName, collapsed }: TotalsListProps) { { let v = format(value, 'financial'); return value > 0 ? '+' + v : value === 0 ? '-' + v : v; @@ -109,6 +114,7 @@ function TotalsList({ prevMonthName, collapsed }: TotalsListProps) { { let v = format(value, 'financial'); return value > 0 ? '+' + v : value === 0 ? '-' + v : v; @@ -118,6 +124,7 @@ function TotalsList({ prevMonthName, collapsed }: TotalsListProps) { { let n = parseInt(value) || 0; let v = format(Math.abs(n), 'financial'); @@ -150,108 +157,105 @@ function ToBudget({ onBudgetAction, }: ToBudgetProps) { let [menuOpen, setMenuOpen] = useState(null); + let sheetName = useSheetName(rolloverBudget.toBudget); + let sheetValue = useSheetValue({ + name: rolloverBudget.toBudget, + value: 0, + }); + let availableValue = parseInt(sheetValue); + let num = isNaN(availableValue) ? 0 : availableValue; + let isNegative = num < 0; return ( - - {node => { - const availableValue = parseInt(node.value); - const num = isNaN(availableValue) ? 0 : availableValue; - const isNegative = num < 0; - - return ( - - {isNegative ? 'Overbudgeted:' : 'To Budget:'} - - ( - - - - )} - > - setMenuOpen('actions')} - data-cellname={node.name} - {...css([ - styles.veryLargeText, - { - fontWeight: 400, - userSelect: 'none', - cursor: 'pointer', - color: isNegative ? colors.r4 : colors.p5, - marginBottom: -1, - borderBottom: '1px solid transparent', - ':hover': { - borderColor: isNegative ? colors.r4 : colors.p5, - }, - }, - ])} - > - {format(num, 'financial')} - - - {menuOpen === 'actions' && ( - setMenuOpen(null)} - > - { - if (type === 'reset-buffer') { - onBudgetAction(month, 'reset-hold'); - setMenuOpen(null); - } else { - setMenuOpen(type); - } - }} - items={[ - { - name: 'transfer', - text: 'Move to a category', - }, - { - name: 'buffer', - text: 'Hold for next month', - }, - { - name: 'reset-buffer', - text: 'Reset next month’s buffer', - }, - ]} - /> - - )} - {menuOpen === 'buffer' && ( - setMenuOpen(null)} - onSubmit={amount => { - onBudgetAction(month, 'hold', { amount }); - }} - /> - )} - {menuOpen === 'transfer' && ( - setMenuOpen(null)} - onSubmit={(amount, category) => { - onBudgetAction(month, 'transfer-available', { - amount, - category, - }); - }} - /> - )} - - - ); - }} - + + {isNegative ? 'Overbudgeted:' : 'To Budget:'} + + ( + + + + )} + > + + setMenuOpen('actions')} + data-cellname={sheetName} + {...css([ + styles.veryLargeText, + { + fontWeight: 400, + userSelect: 'none', + cursor: 'pointer', + color: isNegative ? colors.r4 : colors.p5, + marginBottom: -1, + borderBottom: '1px solid transparent', + ':hover': { + borderColor: isNegative ? colors.r4 : colors.p5, + }, + }, + ])} + > + {format(num, 'financial')} + + + + {menuOpen === 'actions' && ( + setMenuOpen(null)} + > + { + if (type === 'reset-buffer') { + onBudgetAction(month, 'reset-hold'); + setMenuOpen(null); + } else { + setMenuOpen(type); + } + }} + items={[ + { + name: 'transfer', + text: 'Move to a category', + }, + { + name: 'buffer', + text: 'Hold for next month', + }, + { + name: 'reset-buffer', + text: 'Reset next month’s buffer', + }, + ]} + /> + + )} + {menuOpen === 'buffer' && ( + setMenuOpen(null)} + onSubmit={amount => { + onBudgetAction(month, 'hold', { amount }); + }} + /> + )} + {menuOpen === 'transfer' && ( + setMenuOpen(null)} + onSubmit={(amount, category) => { + onBudgetAction(month, 'transfer-available', { + amount, + category, + }); + }} + /> + )} + + ); } diff --git a/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx b/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx index 9cbc268aace..621cce40169 100644 --- a/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx +++ b/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx @@ -291,6 +291,11 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({ valueProps={{ binding: rolloverBudget.groupBalance(id), type: 'financial', + privacyFilter: { + style: { + paddingRight: MONTH_RIGHT_PADDING, + }, + }, }} /> @@ -321,6 +326,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ onEdit(category.id, monthIndex)} @@ -426,6 +432,11 @@ export function IncomeGroupMonth() { valueProps={{ binding: rolloverBudget.groupIncomeReceived, type: 'financial', + privacyFilter: { + style: { + paddingRight: MONTH_RIGHT_PADDING, + }, + }, }} /> diff --git a/packages/desktop-client/src/components/modals/BudgetSummary.js b/packages/desktop-client/src/components/modals/BudgetSummary.js index 467872eeb5b..33a81e77ebf 100644 --- a/packages/desktop-client/src/components/modals/BudgetSummary.js +++ b/packages/desktop-client/src/components/modals/BudgetSummary.js @@ -8,10 +8,11 @@ import { View, Text, Modal, Button } from '../common'; import CellValue from '../spreadsheet/CellValue'; import format from '../spreadsheet/format'; import NamespaceContext from '../spreadsheet/NamespaceContext'; -import SheetValue from '../spreadsheet/SheetValue'; +import useSheetValue from '../spreadsheet/useSheetValue'; function BudgetSummary({ month, modalProps }) { const prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM'); + const budgetAmount = useSheetValue(rolloverBudget.toBudget); return ( @@ -71,29 +72,21 @@ function BudgetSummary({ month, modalProps }) { - - {({ value: amount }) => { - return ( - <> - - {amount < 0 ? 'Overbudget:' : 'To budget:'} - - - {format(amount, 'financial')} - - - ); - }} - + + {budgetAmount < 0 ? 'Overbudget:' : 'To budget:'} + + + {format(budgetAmount, 'financial')} + {' '} Goal templates + + ) : ( ['binding']; + binding: string | Binding; type?: string; formatter?: (value) => ReactNode; style?: CSSProperties; getStyle?: (value) => CSSProperties; + privacyFilter?: ConditionalPrivacyFilterProps['privacyFilter']; }; function CellValue({ @@ -22,25 +30,44 @@ function CellValue({ formatter, style, getStyle, + privacyFilter, }: CellValueProps) { - return ( - - {({ name, value }) => { - return ( - - {formatter ? formatter(value) : format(value, type)} - - ); - }} - + let { fullSheetName } = useSheetName(binding); + let sheetValue = useSheetValue(binding); + + return useMemo( + () => ( + + + {formatter ? formatter(sheetValue) : format(sheetValue, type)} + + + ), + [ + privacyFilter, + type, + style, + getStyle, + fullSheetName, + formatter, + sheetValue, + ], ); } diff --git a/packages/desktop-client/src/components/spreadsheet/NamespaceContext.js b/packages/desktop-client/src/components/spreadsheet/NamespaceContext.ts similarity index 100% rename from packages/desktop-client/src/components/spreadsheet/NamespaceContext.js rename to packages/desktop-client/src/components/spreadsheet/NamespaceContext.ts diff --git a/packages/desktop-client/src/components/spreadsheet/SheetValue.tsx b/packages/desktop-client/src/components/spreadsheet/SheetValue.tsx deleted file mode 100644 index ba32f81f9cf..00000000000 --- a/packages/desktop-client/src/components/spreadsheet/SheetValue.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { - useContext, - useState, - useRef, - useLayoutEffect, - type ReactNode, -} from 'react'; - -import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; - -import NamespaceContext from './NamespaceContext'; - -type Binding = { name: string; value; query?: unknown }; - -type SheetValueProps = { - binding: string | Binding; - initialValue?; - children?: (result: Binding, setCell: () => void) => ReactNode; - onChange?; -}; -// !! Do not use this!! This is deprecated. Use the `useSheetValue` -// hook instead. The reason this hasn't been refactored on top of it -// is because the hook only exposes the value, not the node. It also -// doesn't provide a setter function. In the future there will be -// separate hooks for those things. -export default function SheetValue({ - binding, - initialValue, - children, - onChange, -}: SheetValueProps) { - if (!binding) { - throw new Error('SheetValue binding is required'); - } - - if (global.IS_TESTING && typeof binding !== 'string' && !binding.name) { - binding = { ...binding, name: binding.value.toString() }; - } - - const bindingObj = - typeof binding === 'string' ? { name: binding, value: null } : binding; - - if (bindingObj.name == null) { - throw new Error('Binding name is now required'); - } - - let spreadsheet = useSpreadsheet(); - let sheetName = useContext(NamespaceContext) || '__global'; - let [result, setResult] = useState({ - name: sheetName + '!' + bindingObj.name, - value: initialValue != null ? initialValue : bindingObj.value, - query: bindingObj.query, - }); - let latestOnChange = useRef(onChange); - let latestValue = useRef(result.value); - - /** @deprecated */ - function setCell() { - throw new Error('setCell is not implemented anymore'); - } - - useLayoutEffect(() => { - latestOnChange.current = onChange; - latestValue.current = result.value; - }); - - useLayoutEffect(() => { - if (bindingObj.query) { - spreadsheet.createQuery(sheetName, bindingObj.name, bindingObj.query); - } - - return spreadsheet.bind(sheetName, bindingObj, null, newResult => { - if (latestOnChange.current) { - latestOnChange.current(newResult); - } - - if (newResult.value !== latestValue.current) { - setResult(newResult); - } - }); - }, [sheetName, bindingObj.name]); - - return result.value != null ? <>{children(result, setCell)} : null; -} diff --git a/packages/desktop-client/src/components/spreadsheet/format.js b/packages/desktop-client/src/components/spreadsheet/format.ts similarity index 94% rename from packages/desktop-client/src/components/spreadsheet/format.js rename to packages/desktop-client/src/components/spreadsheet/format.ts index 3cc523f316d..917e10f7929 100644 --- a/packages/desktop-client/src/components/spreadsheet/format.js +++ b/packages/desktop-client/src/components/spreadsheet/format.ts @@ -1,6 +1,6 @@ import { integerToCurrency } from 'loot-core/src/shared/util'; -export default function format(value, type = 'string') { +export default function format(value, type = 'string'): string { switch (type) { case 'string': const val = JSON.stringify(value); diff --git a/packages/desktop-client/src/components/spreadsheet/index.ts b/packages/desktop-client/src/components/spreadsheet/index.ts new file mode 100644 index 00000000000..d0db704b803 --- /dev/null +++ b/packages/desktop-client/src/components/spreadsheet/index.ts @@ -0,0 +1 @@ +export type Binding = string | { name: string; value; query?: unknown }; diff --git a/packages/desktop-client/src/components/spreadsheet/useSheetName.ts b/packages/desktop-client/src/components/spreadsheet/useSheetName.ts new file mode 100644 index 00000000000..a08fac549b1 --- /dev/null +++ b/packages/desktop-client/src/components/spreadsheet/useSheetName.ts @@ -0,0 +1,49 @@ +import { useContext } from 'react'; + +import NamespaceContext from './NamespaceContext'; + +import { type Binding } from '.'; + +function unresolveName(name) { + let idx = name.indexOf('!'); + if (idx !== -1) { + return { + sheet: name.slice(0, idx), + name: name.slice(idx + 1), + }; + } + return { sheet: null, name }; +} + +export default function useSheetName(binding: Binding) { + if (!binding) { + throw new Error('Sheet binding is required'); + } + + const isStringBinding = typeof binding === 'string'; + + let bindingName = isStringBinding ? binding : binding.name; + + if (global.IS_TESTING && !isStringBinding && !bindingName) { + bindingName = binding.value.toString(); + } + + if (bindingName == null) { + throw new Error('Binding name is now required'); + } + + // Get the current sheet name, and unresolve the binding name if + // necessary (you might pass a fully resolved name like foo!name) + let sheetName = useContext(NamespaceContext) || '__global'; + let unresolved = unresolveName(bindingName); + if (unresolved.sheet) { + sheetName = unresolved.sheet; + bindingName = unresolved.name; + } + + return { + sheetName, + bindingName, + fullSheetName: `${sheetName}!${bindingName}`, + }; +} diff --git a/packages/desktop-client/src/components/spreadsheet/useSheetValue.js b/packages/desktop-client/src/components/spreadsheet/useSheetValue.js deleted file mode 100644 index 3f626885c15..00000000000 --- a/packages/desktop-client/src/components/spreadsheet/useSheetValue.js +++ /dev/null @@ -1,73 +0,0 @@ -import { useContext, useState, useRef, useLayoutEffect } from 'react'; - -import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; - -import NamespaceContext from './NamespaceContext'; - -function unresolveName(name) { - let idx = name.indexOf('!'); - if (idx !== -1) { - return { - sheet: name.slice(0, idx), - name: name.slice(idx + 1), - }; - } - return { sheet: null, name }; -} - -export default function useSheetValue(binding, onChange) { - if (!binding) { - throw new Error('SheetValue binding is required'); - } - if (global.IS_TESTING && typeof binding !== 'string' && !binding.name) { - binding = { ...binding, name: binding.value.toString() }; - } - - binding = - typeof binding === 'string' ? { name: binding, value: null } : binding; - - if (binding.name == null) { - throw new Error('Binding name is now required'); - } - - // Get the current sheet name, and unresolve the binding name if - // necessary (you might pass a fully resolve name like foo!name) - let sheetName = useContext(NamespaceContext) || '__global'; - let unresolved = unresolveName(binding.name); - if (unresolved.sheet) { - sheetName = unresolved.sheet; - binding = { ...binding, name: unresolved.name }; - } - - let spreadsheet = useSpreadsheet(); - let [result, setResult] = useState({ - name: sheetName + '!' + binding.name, - value: binding.value === undefined ? null : binding.value, - query: binding.query, - }); - let latestOnChange = useRef(onChange); - let latestValue = useRef(result.value); - - useLayoutEffect(() => { - latestOnChange.current = onChange; - latestValue.current = result.value; - }); - - useLayoutEffect(() => { - if (binding.query) { - spreadsheet.createQuery(sheetName, binding.name, binding.query); - } - - return spreadsheet.bind(sheetName, binding, null, newResult => { - if (latestOnChange.current) { - latestOnChange.current(newResult); - } - - if (newResult.value !== latestValue.current) { - setResult(newResult); - } - }); - }, [sheetName, binding.name]); - - return result.value; -} diff --git a/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts b/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts new file mode 100644 index 00000000000..154ea11fd10 --- /dev/null +++ b/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts @@ -0,0 +1,49 @@ +import { useState, useRef, useLayoutEffect } from 'react'; + +import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; + +import useSheetName from './useSheetName'; + +import { type Binding } from '.'; + +export default function useSheetValue( + binding: Binding, + onChange?: (result) => void, +) { + let { sheetName, fullSheetName } = useSheetName(binding); + + const bindingObj = + typeof binding === 'string' ? { name: binding, value: null } : binding; + + let spreadsheet = useSpreadsheet(); + let [result, setResult] = useState({ + name: fullSheetName, + value: bindingObj.value === undefined ? null : bindingObj.value, + query: bindingObj.query, + }); + let latestOnChange = useRef(onChange); + let latestValue = useRef(result.value); + + useLayoutEffect(() => { + latestOnChange.current = onChange; + latestValue.current = result.value; + }); + + useLayoutEffect(() => { + if (bindingObj.query) { + spreadsheet.createQuery(sheetName, bindingObj.name, bindingObj.query); + } + + return spreadsheet.bind(sheetName, binding, null, newResult => { + if (latestOnChange.current) { + latestOnChange.current(newResult); + } + + if (newResult.value !== latestValue.current) { + setResult(newResult); + } + }); + }, [sheetName, bindingObj.name]); + + return result.value; +} diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 5c1a4aff44f..3598314bf97 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -41,8 +41,14 @@ import { } from './common'; import FixedSizeList from './FixedSizeList'; import { KeyHandlers } from './KeyHandlers'; +import { + ConditionalPrivacyFilter, + mergeConditionalPrivacyFilterProps, + type ConditionalPrivacyFilterProps, +} from './PrivacyFilter'; +import { type Binding } from './spreadsheet'; import format from './spreadsheet/format'; -import SheetValue from './spreadsheet/SheetValue'; +import useSheetValue from './spreadsheet/useSheetValue'; export const ROW_HEIGHT = 32; const TABLE_BACKGROUND_COLOR = colors.n11; @@ -193,6 +199,7 @@ type CellProps = Omit, 'children' | 'value'> & { value?: string; valueStyle?: CSSProperties; onExpose?: (name: string) => void; + privacyFilter?: ConditionalPrivacyFilterProps['privacyFilter']; }; export function Cell({ width, @@ -210,6 +217,7 @@ export function Cell({ style, valueStyle, unexposedContent, + privacyFilter, ...viewProps }: CellProps) { let mouseCoords = useRef(null); @@ -237,6 +245,80 @@ export function Cell({ alignItems: alignItems, }; + let conditionalPrivacyFilter = useMemo( + () => ( + + {plain ? ( + children + ) : exposed ? ( + // @ts-expect-error Missing props refinement + children() + ) : ( + (mouseCoords.current = [e.clientX, e.clientY])} + // When testing, allow the click handler to be used instead + onClick={ + global.IS_TESTING + ? () => onExpose?.(name) + : e => { + if ( + mouseCoords.current && + Math.abs(e.clientX - mouseCoords.current[0]) < 5 && + Math.abs(e.clientY - mouseCoords.current[1]) < 5 + ) { + onExpose?.(name); + } + } + } + > + {unexposedContent || ( + + )} + + )} + + ), + [ + privacyFilter, + focused, + exposed, + children, + plain, + exposed, + valueStyle, + onExpose, + name, + unexposedContent, + value, + formatter, + ], + ); + return ( - {plain ? ( - children - ) : exposed ? ( - // @ts-expect-error Missing props refinement - children() - ) : ( - (mouseCoords.current = [e.clientX, e.clientY])} - // When testing, allow the click handler to be used instead - onClick={ - global.IS_TESTING - ? () => onExpose && onExpose(name) - : e => { - if ( - mouseCoords.current && - Math.abs(e.clientX - mouseCoords.current[0]) < 5 && - Math.abs(e.clientY - mouseCoords.current[1]) < 5 - ) { - onExpose?.(name); - } - } - } - > - {unexposedContent || ( - - )} - - )} + {conditionalPrivacyFilter} ); } @@ -694,11 +736,12 @@ export function SelectCell({ } type SheetCellValueProps = { - binding: ComponentProps['binding']; + binding: Binding; type: string; getValueStyle?: (value: unknown) => CSSProperties; formatExpr?: (value) => string; unformatExpr?: (value: string) => unknown; + privacyFilter?: ConditionalPrivacyFilterProps['privacyFilter']; }; type SheetCellProps = ComponentProps & { @@ -714,52 +757,55 @@ export function SheetCell({ onSave, ...props }: SheetCellProps) { - const { binding, type, getValueStyle, formatExpr, unformatExpr } = valueProps; + const { + binding, + type, + getValueStyle, + formatExpr, + unformatExpr, + privacyFilter, + } = valueProps; + + let sheetValue = useSheetValue(binding, e => { + // "close" the cell if it's editing + if (props.exposed && inputProps && inputProps.onBlur) { + inputProps.onBlur(e); + } + }); return ( - { - // "close" the cell if it's editing - if (props.exposed && inputProps && inputProps.onBlur) { - inputProps.onBlur(e); - } - }} + + props.formatter ? props.formatter(value, type) : format(value, type) + } + privacyFilter={ + privacyFilter != null + ? privacyFilter + : type === 'financial' + ? true + : undefined + } + data-cellname={sheetValue} > - {node => { + {() => { return ( - - props.formatter - ? props.formatter(value, type) - : format(value, type) - } - data-cellname={node.name} - > - {() => { - return ( - { - onSave(unformatExpr ? unformatExpr(value) : value); - }} - style={{ textAlign }} - {...inputProps} - /> - ); + { + onSave(unformatExpr ? unformatExpr(value) : value); }} - + style={{ textAlign }} + {...inputProps} + /> ); }} - + ); } diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.js b/packages/desktop-client/src/components/transactions/TransactionsTable.js index 6b3b17b3e32..7df78b74e1b 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.js +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.js @@ -1211,6 +1211,9 @@ const Transaction = memo(function Transaction(props) { value: debit === '' && credit === '' ? '0.00' : debit, onUpdate: onUpdate.bind(null, 'debit'), }} + privacyFilter={{ + activationFilters: [!isTemporaryId(transaction.id)], + }} /> {showBalance && ( @@ -1243,6 +1249,7 @@ const Transaction = memo(function Transaction(props) { style={[styles.tnum, amountStyle]} width={88} textAlign="right" + privacyFilter /> )} diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.js b/packages/desktop-client/src/components/transactions/TransactionsTable.test.js index c8f2719f429..c65229e2ff0 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.js +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.js @@ -21,6 +21,7 @@ import { import { integerToCurrency } from 'loot-core/src/shared/util'; import { SelectedProviderWithItems } from '../../hooks/useSelected'; +import { ResponsiveProvider } from '../../ResponsiveProvider'; import { SplitsExpandedProvider, TransactionTable } from './TransactionsTable'; @@ -115,26 +116,28 @@ function LiveTransactionTable(props) { // hook dependencies haven't changed return ( - transactions.map(t => t.id)} - > - - {}} - payees={payees} - addNotification={n => console.log(n)} - onSave={onSave} - onSplit={onSplit} - onAdd={onAdd} - onAddSplit={onAddSplit} - onCreatePayee={onCreatePayee} - /> - - + + transactions.map(t => t.id)} + > + + {}} + payees={payees} + addNotification={n => console.log(n)} + onSave={onSave} + onSplit={onSplit} + onAdd={onAdd} + onAddSplit={onAddSplit} + onCreatePayee={onCreatePayee} + /> + + + ); } diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index b859204d673..7d3a49101e5 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -5,6 +5,7 @@ import { type FeatureFlag } from 'loot-core/src/client/state-types/prefs'; const DEFAULT_FEATURE_FLAG_STATE: Record = { reportBudget: false, goalTemplatesEnabled: false, + privacyMode: false, }; export default function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/desktop-client/src/icons/v2/Eye.js b/packages/desktop-client/src/icons/v2/Eye.js new file mode 100644 index 00000000000..56b65a2e2a0 --- /dev/null +++ b/packages/desktop-client/src/icons/v2/Eye.js @@ -0,0 +1,55 @@ +import * as React from 'react'; + +const SvgEye = props => ( + + + + + +); + +export default SvgEye; diff --git a/packages/desktop-client/src/icons/v2/EyeSlashed.js b/packages/desktop-client/src/icons/v2/EyeSlashed.js new file mode 100644 index 00000000000..50f843673d9 --- /dev/null +++ b/packages/desktop-client/src/icons/v2/EyeSlashed.js @@ -0,0 +1,17 @@ +import * as React from 'react'; + +const SvgEyeSlashed = props => ( + + + +); + +export default SvgEyeSlashed; diff --git a/packages/desktop-client/src/icons/v2/eye-slashed.svg b/packages/desktop-client/src/icons/v2/eye-slashed.svg new file mode 100644 index 00000000000..04017893d32 --- /dev/null +++ b/packages/desktop-client/src/icons/v2/eye-slashed.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/desktop-client/src/icons/v2/eye.svg b/packages/desktop-client/src/icons/v2/eye.svg new file mode 100644 index 00000000000..0ed1469473d --- /dev/null +++ b/packages/desktop-client/src/icons/v2/eye.svg @@ -0,0 +1,8 @@ + + + + + +