diff --git a/scripts/__tests__/ds-lint-rules.test.ts b/scripts/__tests__/ds-lint-rules.test.ts index e913065994..26a9464085 100644 --- a/scripts/__tests__/ds-lint-rules.test.ts +++ b/scripts/__tests__/ds-lint-rules.test.ts @@ -117,10 +117,12 @@ describe('fontWeightOnTypeToken (countWeightStacks)', () => { 'font-[650.5]', 'font-(weight:--my-weight)', 'font-[weight:var(--my-font-weight)]', + // untyped custom property = font-weight in tailwind 4 + 'font-(--my-weight)', ]) { expect(countWeightStacks(`

`)).toBe(1) } - for (const notWeight of ['font-sans', 'font-roboto', 'font-(--brand-face)', 'font-blackout']) { + for (const notWeight of ['font-sans', 'font-roboto', 'font-(family-name:--brand-face)', 'font-blackout']) { expect(countWeightStacks(`

`)).toBe(0) } }) diff --git a/scripts/ds-lint-rules.cjs b/scripts/ds-lint-rules.cjs index abd6a321b5..85e939b41b 100644 --- a/scripts/ds-lint-rules.cjs +++ b/scripts/ds-lint-rules.cjs @@ -49,10 +49,11 @@ function countOffScaleSpacing(text) { // per-line pass over the remaining text. // stock weight names + the theme's own extraBlack (globals.css // --font-weight-extraBlack, registered in tw.ts) + arbitrary numeric brackets -// (decimals included) + the font-(weight:…) custom-property form. bare -// font-(--x) is a font-FAMILY custom property, not a weight — excluded. +// (decimals included) + the font-(weight:…) and bare font-(--x) custom-property +// forms: tailwind 4 compiles an untyped font-(--x) as font-WEIGHT, and a family +// needs the family-name: hint, so only that typed form is excluded. const WEIGHT_STACK_RE = - /\bfont-(?:thin|extralight|light|normal|medium|semibold|extrabold|extraBlack|bold|black|\[[0-9]+(?:\.[0-9]+)?\]|\[weight:[^\]]+\]|\(weight:[^)]+\))(?![a-zA-Z0-9-])/ + /\bfont-(?:thin|extralight|light|normal|medium|semibold|extrabold|extraBlack|bold|black|\[[0-9]+(?:\.[0-9]+)?\]|\[weight:[^\]]+\]|\(weight:[^)]+\)|\(--[^)]+\))(?![a-zA-Z0-9-])/ const TYPE_TOKEN_RE = /\btext-(?:body|heading|label|button)-[a-z-]+\b/ function classNameExpressions(text) { diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index 2fc5ea0bb1..d598a052ed 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -165,6 +165,9 @@ export const Claim = ({}) => { // find the claimed event for timestamp const claimedEvent = claimLinkData.events?.find((e) => e.status === 'CLAIMED') + // the sender's cancel/reclaim stamp; the events fallback only carries a + // date when a claim attempt was recorded against the link + const cancelledStamp = claimLinkData.cancelledAt ?? claimLinkData.events?.[0]?.timestamp let details: Partial = { id: claimLinkData.pubKey, @@ -179,10 +182,7 @@ export const Claim = ({}) => { initials: getInitialsFromName(recipientName), memo: claimLinkData.textContent, attachmentUrl: claimLinkData.fileUrl, - cancelledDate: - status === 'cancelled' && claimLinkData.events?.[0] - ? new Date(claimLinkData.events[0].timestamp) - : undefined, + cancelledDate: status === 'cancelled' && cancelledStamp ? new Date(cancelledStamp) : undefined, txHash: claimLinkData.claim?.txHash, extraDataForDrawer: { isLinkTransaction: true, diff --git a/src/components/Claim/__tests__/claim-states.test.tsx b/src/components/Claim/__tests__/claim-states.test.tsx index 944e23dcfc..5ddaadf042 100644 --- a/src/components/Claim/__tests__/claim-states.test.tsx +++ b/src/components/Claim/__tests__/claim-states.test.tsx @@ -165,8 +165,9 @@ jest.mock('@/components/TransactionDetails/transactionTransformer', () => ({ REWARD_TOKENS: {}, })) +const mockReceipt = jest.fn((_props: any) =>

Receipt
) jest.mock('@/components/TransactionDetails/TransactionDetailsReceipt', () => ({ - TransactionDetailsReceipt: (_props: any) =>
Receipt
, + TransactionDetailsReceipt: (props: any) => mockReceipt(props), })) jest.mock('@/context/ModalsContext', () => ({ @@ -415,6 +416,33 @@ describe('GROUP 3: Already Claimed / Cancelled', () => { }) }) + // A sender's cancel/reclaim leaves no SEND_LINK_CLAIM intent behind, so the + // `events` fallback is empty and the receipt used to show no cancellation + // date at all. GET /send-links carries the row's own cancelledAt as of + // peanut-api-ts#1525; until that ships the fallback keeps today's behaviour. + test('CANCELLED receipt shows the cancellation date from cancelledAt', async () => { + mockUseAuth.mockReturnValue({ + user: { user: { userId: 'sender-123' } }, + isFetchingUser: false, + fetchUser: jest.fn(), + }) + mockSendLinksApi.get.mockResolvedValue( + makeSendLink({ + status: 'CANCELLED', + cancelledAt: '2026-04-20T12:00:00.000Z', + sender: { userId: 'sender-123', username: 'alice' }, + }) + ) + + renderClaim() + + await waitFor(() => { + expect(screen.getByTestId('transaction-details-receipt')).toBeInTheDocument() + }) + const { transaction } = mockReceipt.mock.calls.at(-1)![0] + expect(transaction.cancelledDate).toEqual(new Date('2026-04-20T12:00:00.000Z')) + }) + test('CLAIMING link (in progress) shows as already claimed', async () => { const link = makeSendLink({ status: 'CLAIMING' }) mockSendLinksApi.get.mockResolvedValue(link) diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index a26cd977a8..cba2e22ef7 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -19,6 +19,7 @@ import { IntlWrapper } from '@/test-utils/intl' import SupportDrawer from '../index' import { isCapacitor } from '@/utils/capacitor' import { SUPPORT_EMAIL } from '@/constants/crisp' +import { dispatchBackPress, resetBackHandlersForTests } from '@/utils/back-handler' const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) @@ -40,10 +41,11 @@ const modalsState: { supportPrefilledMessage: string | undefined; isSupportModal supportPrefilledMessage: undefined, isSupportModalOpen: true, } +const mockSetIsSupportModalOpen = jest.fn() jest.mock('@/context/ModalsContext', () => ({ useModalsContext: () => ({ isSupportModalOpen: modalsState.isSupportModalOpen, - setIsSupportModalOpen: jest.fn(), + setIsSupportModalOpen: mockSetIsSupportModalOpen, supportPrefilledMessage: modalsState.supportPrefilledMessage, }), })) @@ -671,3 +673,42 @@ describe('SupportDrawer — native open runs once per open cycle', () => { expect(nativeCrisp.sendMessage).not.toHaveBeenCalled() }) }) + +// The hand-rolled overlay was left off the LIFO back stack PR #2920 gave the DS +// Drawer and Modal, so Android back with the sheet open navigated the page +// underneath instead of closing the sheet. +describe('SupportDrawer — Android hardware back', () => { + beforeEach(() => { + mockUseCrispUserData.mockReset().mockReturnValue({}) + mockUseCrispTokenId.mockReset().mockReturnValue(undefined) + mockIsCapacitor.mockReset().mockReturnValue(false) + mockSetIsSupportModalOpen.mockReset() + resetBackHandlersForTests() + }) + + it('closes the sheet and consumes the press while open', () => { + modalsState.isSupportModalOpen = true + render() + + let consumed = false + act(() => { + consumed = dispatchBackPress() + }) + + expect(consumed).toBe(true) + expect(mockSetIsSupportModalOpen).toHaveBeenCalledWith(false) + }) + + it('leaves the press to the page while closed', () => { + modalsState.isSupportModalOpen = false + render() + + let consumed = true + act(() => { + consumed = dispatchBackPress() + }) + + expect(consumed).toBe(false) + expect(mockSetIsSupportModalOpen).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index dcb3b52326..0ecda33cc0 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -6,6 +6,7 @@ import { useModalsContext } from '@/context/ModalsContext' import { useCrispUserData } from '@/hooks/useCrispUserData' import { useCrispTokenId } from '@/hooks/useCrispTokenId' import { useVisualViewport } from '@/hooks/useVisualViewport' +import { useBackHandler } from '@/hooks/useBackHandler' import Loading from '../Loading' import { Button } from '@/components/0_Bruddle/Button' import { @@ -389,6 +390,14 @@ const SupportDrawer = () => { return () => window.removeEventListener('message', handleMessage) }, []) + // Android hardware back closes the sheet instead of navigating the page + // underneath. Hand-rolled overlay, so it registers itself (the DS Drawer + // and Modal do this internally). + useBackHandler(() => { + setIsSupportModalOpen(false) + return true + }, isSupportModalOpen) + // close on escape useEffect(() => { if (!isSupportModalOpen) return diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx index 5c502e08a7..45ab2e73dc 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -93,11 +93,24 @@ export function StickyMobileCTA({ strings }: { strings: LandingStrings }) { ) : ( - - - +
+ + + + + {strings.logIn} + +
)} } diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index d1c8ceab4a..79a3cc41b2 100644 --- a/src/components/LandingPage/hero.tsx +++ b/src/components/LandingPage/hero.tsx @@ -240,6 +240,16 @@ export function Hero({ {primaryCta ? renderCTAButton(primaryCta, 'primary') : customCta ? renderCustomCta() : null} {secondaryCta && renderCTAButton(secondaryCta, 'secondary')} + {/* Returning users with an expired session had no way back in from the + marketing site: every CTA pointed at signup. `?step=login` lands on + the passkey Log In step (setup-entry.ts). */} + + {strings.logIn} + { expect(redactNativePath('/not-a-declared-route/secret-value')).toBe('/:id/:id') }) + // The authority can carry userinfo, which is attacker-controlled on a link + // and would otherwise survive into `raw` next to the redacted path. + it('drops userinfo from the authority', () => { + expect(redactNativePath('https://CLAIM_SECRET@peanut.me/qr/aB3xK9mQ2pL7vN4z')).toBe('https://peanut.me/qr/:id') + expect(redactNativePath('https://user:pass@peanut.me/home')).toBe('https://peanut.me/home') + }) + // A locale or other prefix must not shift the root out of a positional // window and turn the whole path into placeholders. it('finds the route family behind a prefix segment', () => { diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 3990ae9e59..bb408e2980 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -370,9 +370,12 @@ const TELEMETRY_SAFE_SEGMENTS = new Set(['success', 'bank', 'manteca', 'crypto', export function redactNativePath(value: string): string { const beforeQuery = value.split('#')[0].split('?')[0] // Keep scheme://host so a peanut.me universal link stays distinguishable - // from a custom-scheme launch — neither carries an identifier. - const prefix = beforeQuery.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i)?.[0] ?? '' - const path = beforeQuery.slice(prefix.length) + // from a custom-scheme launch — neither carries an identifier. Userinfo + // (`https://secret@peanut.me/…`) is dropped: it is attacker-controlled + // input on a link and would otherwise ride into telemetry intact. + const authority = beforeQuery.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i)?.[0] ?? '' + const prefix = authority.replace(/\/\/[^/]*@/, '//') + const path = beforeQuery.slice(authority.length) const redacted = path .split('/') .map((segment) => {