diff --git a/apps/web/src/components/transactions/TxDetails/index.tsx b/apps/web/src/components/transactions/TxDetails/index.tsx index 412df2815d..f02d6106ae 100644 --- a/apps/web/src/components/transactions/TxDetails/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/index.tsx @@ -36,6 +36,7 @@ import { FEATURES } from '@/utils/chains' import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { asError } from '@/services/exceptions/utils' import { POLLING_INTERVAL } from '@/config/constants' +import { TxNote } from '@/features/tx-notes' export const NOT_AVAILABLE = 'n/a' @@ -82,6 +83,10 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement <> {/* /Details */}
+
+ +
+
diff --git a/apps/web/src/components/transactions/TxDetails/styles.module.css b/apps/web/src/components/transactions/TxDetails/styles.module.css index 5c3bc427dc..afd3f2c24c 100644 --- a/apps/web/src/components/transactions/TxDetails/styles.module.css +++ b/apps/web/src/components/transactions/TxDetails/styles.module.css @@ -12,9 +12,20 @@ } .shareLink { - position: absolute; - right: 16px; - top: 16px; + display: flex; + justify-content: flex-end; + margin: var(--space-1); + margin-bottom: -40px; +} + +.txNote { + margin: var(--space-1) 0; + padding: 0 var(--space-2) var(--space-2); + border-bottom: 1px solid var(--color-border-light); +} + +.txNote:empty { + display: none; } .loading, diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx b/apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx index 05f063cd5f..b92f3938fd 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx @@ -17,6 +17,7 @@ import Stack from '@mui/system/Stack' export const ProposerForm = ({ safeTx, + origin, disableSubmit = false, txActions, txSecurity, @@ -51,7 +52,7 @@ export const ProposerForm = ({ setIsRejectedByUser(false) try { - const txId = await signProposerTx(safeTx) + const txId = await signProposerTx(safeTx, origin) onSubmit?.(txId) } catch (_err) { const err = asError(_err) diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx b/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx index e2c605d932..7138e12536 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -86,7 +86,7 @@ export const SignForm = ({ onSubmit?.(resultTxId) } - if (signer?.isSafe) { + if (!isAddingToBatch && signer?.isSafe) { setTxFlow(, undefined, false) } else { setTxFlow(undefined) @@ -101,8 +101,6 @@ export const SignForm = ({ const submitDisabled = !safeTx || !isSubmittable || disableSubmit || cannotPropose || (needsRiskConfirmation && !isRiskConfirmed) - const isSafeAppTransaction = !!origin - return (
{hasSigned && You have already signed this transaction.} @@ -135,7 +133,7 @@ export const SignForm = ({ {isCreation && !isBatch && ( )} diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx index e7d4fe888a..db523d1bde 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -33,6 +33,7 @@ import ConfirmationView from '../confirmation-views' import { SignerForm } from './SignerForm' import { useSigner } from '@/hooks/wallets/useWallet' import { trackTxEvents } from './tracking' +import { TxNoteForm, encodeTxNote } from '@/features/tx-notes' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void @@ -64,6 +65,7 @@ export const SignOrExecuteForm = ({ isCreation?: boolean txDetails?: TransactionDetails }): ReactElement => { + const [customOrigin, setCustomOrigin] = useState(props.origin) const { transactionExecution } = useAppSelector(selectSettings) const [shouldExecute, setShouldExecute] = useState(transactionExecution) const isNewExecutableTx = useImmediatelyExecutable() && isCreation @@ -108,10 +110,10 @@ export const SignOrExecuteForm = ({ isRoleExecution, isProposerCreation, !!signer?.isSafe, - props.origin, + customOrigin, ) }, - [chainId, isCreation, onSubmit, trigger, signer?.isSafe, props.origin], + [chainId, isCreation, onSubmit, trigger, signer?.isSafe, customOrigin], ) const onRoleExecutionSubmit = useCallback( @@ -124,6 +126,49 @@ export const SignOrExecuteForm = ({ [onFormSubmit], ) + const onNoteSubmit = useCallback( + (note: string) => { + setCustomOrigin(encodeTxNote(note, props.origin)) + }, + [setCustomOrigin, props.origin], + ) + + const getForm = () => { + const commonProps = { + ...props, + safeTx, + isCreation, + origin: customOrigin, + onSubmit: onFormSubmit, + } + if (isCounterfactualSafe && !isProposing) { + return + } + + if (!isCounterfactualSafe && willExecute && !isProposing) { + return + } + + if (!isCounterfactualSafe && willExecuteThroughRole) { + return ( + + ) + } + + if (!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isProposing) { + return + } + + if (isProposing) { + return + } + } + return ( <> @@ -149,6 +194,8 @@ export const SignOrExecuteForm = ({ {!isCounterfactualSafe && !props.isRejection && } + + @@ -179,32 +226,7 @@ export const SignOrExecuteForm = ({ - {isCounterfactualSafe && !isProposing && ( - - )} - {!isCounterfactualSafe && willExecute && !isProposing && ( - - )} - {!isCounterfactualSafe && willExecuteThroughRole && ( - - )} - {!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isProposing && ( - - )} - - {isProposing && } + {getForm()} ) diff --git a/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts b/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts index 2474b104c6..6850497a6e 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts @@ -34,7 +34,7 @@ type TxActions = { origin?: string, isRelayed?: boolean, ) => Promise - signProposerTx: (safeTx?: SafeTransaction) => Promise + signProposerTx: (safeTx?: SafeTransaction, origin?: string) => Promise proposeTx: (safeTx: SafeTransaction, txId?: string, origin?: string) => Promise } @@ -135,14 +135,14 @@ export const useTxActions = (): TxActions => { return tx.txId } - const signProposerTx: TxActions['signProposerTx'] = async (safeTx) => { + const signProposerTx: TxActions['signProposerTx'] = async (safeTx, origin) => { assertTx(safeTx) assertProvider(wallet?.provider) assertOnboard(onboard) const signedTx = await dispatchProposerTxSigning(safeTx, wallet) - const tx = await _propose(wallet.address, signedTx) + const tx = await _propose(wallet.address, signedTx, undefined, origin) return tx.txId } diff --git a/apps/web/src/features/tx-notes/TxNote.tsx b/apps/web/src/features/tx-notes/TxNote.tsx new file mode 100644 index 0000000000..3d402e0b3e --- /dev/null +++ b/apps/web/src/features/tx-notes/TxNote.tsx @@ -0,0 +1,44 @@ +import { Tooltip, Typography, Stack } from '@mui/material' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import InfoIcon from '@/public/images/notifications/info.svg' +import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' +import EthHashInfo from '@/components/common/EthHashInfo' + +export function TxNote({ txDetails }: { txDetails: TransactionDetails | undefined }) { + // @FIXME: update CGW types to include note + const note = (txDetails as TransactionDetails & { note: string | null })?.note + + if (!note) return null + + const creator = + isMultisigDetailedExecutionInfo(txDetails?.detailedExecutionInfo) && txDetails?.detailedExecutionInfo.proposer + + return ( +
+ + Note + + By + {creator ? ( + + ) : ( + transaction creator + )} + + } + arrow + > + + + + + + + + {note} + +
+ ) +} diff --git a/apps/web/src/features/tx-notes/TxNoteForm.tsx b/apps/web/src/features/tx-notes/TxNoteForm.tsx new file mode 100644 index 0000000000..933bb6111e --- /dev/null +++ b/apps/web/src/features/tx-notes/TxNoteForm.tsx @@ -0,0 +1,16 @@ +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import TxCard from '@/components/tx-flow/common/TxCard' +import { TxNote } from './TxNote' +import { TxNoteInput } from './TxNoteInput' + +export function TxNoteForm({ + isCreation, + txDetails, + onSubmit, +}: { + isCreation: boolean + txDetails?: TransactionDetails + onSubmit: (note: string) => void +}) { + return {isCreation ? : } +} diff --git a/apps/web/src/features/tx-notes/TxNoteInput.tsx b/apps/web/src/features/tx-notes/TxNoteInput.tsx new file mode 100644 index 0000000000..f3b7f6a438 --- /dev/null +++ b/apps/web/src/features/tx-notes/TxNoteInput.tsx @@ -0,0 +1,58 @@ +import { useCallback, useState } from 'react' +import { InputAdornment, Stack, TextField, Typography } from '@mui/material' +import InfoIcon from '@/public/images/notifications/info.svg' +import { MODALS_EVENTS, trackEvent } from '@/services/analytics' + +const MAX_NOTE_LENGTH = 120 + +export const TxNoteInput = ({ onSubmit }: { onSubmit: (note: string) => void }) => { + const [note, setNote] = useState('') + + const onInput = useCallback((e: React.ChangeEvent) => { + setNote(e.target.value) + }, []) + + const onChange = useCallback( + (e: React.ChangeEvent) => { + onSubmit(e.target.value.slice(0, MAX_NOTE_LENGTH)) + trackEvent(MODALS_EVENTS.ADD_TX_NOTE) + }, + [onSubmit], + ) + + return ( + <> + + What does this transaction do? + + Optional + + + + + + {note.length}/{MAX_NOTE_LENGTH} + + + ), + }, + }} + onInput={onInput} + onChange={onChange} + /> + + + + This note will be publicly visible and accessible to anyone. + + + ) +} diff --git a/apps/web/src/features/tx-notes/encodeTxNote.test.ts b/apps/web/src/features/tx-notes/encodeTxNote.test.ts new file mode 100644 index 0000000000..04f815ae2b --- /dev/null +++ b/apps/web/src/features/tx-notes/encodeTxNote.test.ts @@ -0,0 +1,31 @@ +import { faker } from '@faker-js/faker' +import { encodeTxNote } from './encodeTxNote' + +describe('encodeTxNote', () => { + it('should encode tx note with an existing origin', () => { + const note = faker.lorem.sentence() + const url = faker.internet.url() + const origin = JSON.stringify({ url }) + const result = encodeTxNote(note, origin) + expect(result).toEqual(JSON.stringify({ url, note }, null, 0)) + }) + + it('should encode tx note with an empty origin', () => { + const note = faker.lorem.sentence() + const result = encodeTxNote(note) + expect(result).toEqual(JSON.stringify({ note }, null, 0)) + }) + + it('should encode tx note with an invalid origin', () => { + const note = faker.lorem.sentence() + const result = encodeTxNote(note, 'sdfgdsfg') + expect(result).toEqual(JSON.stringify({ note }, null, 0)) + }) + + it('should trim the note if origin exceeds the max length', () => { + const note = 'a'.repeat(200) + const url = 'http://example.com' + const result = encodeTxNote(note, JSON.stringify({ url })) + expect(result).toEqual(JSON.stringify({ url, note: 'a'.repeat(172) }, null, 0)) + }) +}) diff --git a/apps/web/src/features/tx-notes/encodeTxNote.ts b/apps/web/src/features/tx-notes/encodeTxNote.ts new file mode 100644 index 0000000000..c6740e142c --- /dev/null +++ b/apps/web/src/features/tx-notes/encodeTxNote.ts @@ -0,0 +1,29 @@ +const MAX_ORIGIN_LENGTH = 200 + +const stringifyOrigin = (origin: Record): string => JSON.stringify(origin, null, 0) + +export function encodeTxNote(note: string, origin = ''): string { + let originalOrigin = {} + + if (origin) { + try { + originalOrigin = JSON.parse(origin) + } catch { + // Ignore, invalid JSON + } + } + + let result = stringifyOrigin({ + ...originalOrigin, + note, + }) + + if (result.length > MAX_ORIGIN_LENGTH) { + result = stringifyOrigin({ + ...originalOrigin, + note: note.slice(0, MAX_ORIGIN_LENGTH - origin.length), + }) + } + + return result +} diff --git a/apps/web/src/features/tx-notes/index.tsx b/apps/web/src/features/tx-notes/index.tsx new file mode 100644 index 0000000000..fda7533942 --- /dev/null +++ b/apps/web/src/features/tx-notes/index.tsx @@ -0,0 +1,7 @@ +import { featureToggled, FEATURES } from '@/utils/featureToggled' +import { TxNote as TxNoteComponent } from './TxNote' +import { TxNoteForm as TxNoteFormComponent } from './TxNoteForm' + +export const TxNote = featureToggled(TxNoteComponent, FEATURES.TX_NOTES) +export const TxNoteForm = featureToggled(TxNoteFormComponent, FEATURES.TX_NOTES) +export * from './encodeTxNote' diff --git a/apps/web/src/services/analytics/events/modals.ts b/apps/web/src/services/analytics/events/modals.ts index eebafc3291..bf9d1dd5ab 100644 --- a/apps/web/src/services/analytics/events/modals.ts +++ b/apps/web/src/services/analytics/events/modals.ts @@ -87,6 +87,11 @@ export const MODALS_EVENTS = { category: MODALS_CATEGORY, event: EventType.CLICK, }, + ADD_TX_NOTE: { + action: 'Add tx note', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, } export enum MODAL_NAVIGATION { diff --git a/apps/web/src/utils/chains.ts b/apps/web/src/utils/chains.ts index 971212b90d..0336edc41d 100644 --- a/apps/web/src/utils/chains.ts +++ b/apps/web/src/utils/chains.ts @@ -39,6 +39,7 @@ export enum FEATURES { PROPOSERS = 'PROPOSERS', TARGETED_SURVEY = 'TARGETED_SURVEY', BRIDGE = 'BRIDGE', + TX_NOTES = 'TX_NOTES', } export const FeatureRoutes = { diff --git a/apps/web/src/utils/featureToggled.tsx b/apps/web/src/utils/featureToggled.tsx new file mode 100644 index 0000000000..ef76ace617 --- /dev/null +++ b/apps/web/src/utils/featureToggled.tsx @@ -0,0 +1,14 @@ +import type { ComponentType } from 'react' +import type { FEATURES } from '@/utils/chains' +import { useHasFeature } from '@/hooks/useChains' + +export { FEATURES } from '@/utils/chains' + +export const featureToggled =

>(Component: ComponentType

, feature: FEATURES) => { + const ToggledComponent = (props: P) => { + const hasFeature = useHasFeature(feature) + return hasFeature ? : null + } + ToggledComponent.displayName = Component.displayName || Component.name + return ToggledComponent +}