diff --git a/src/components/email/structured-mail/StructuredMailBanner.tsx b/src/components/email/structured-mail/StructuredMailBanner.tsx new file mode 100644 index 00000000..8e75a7e3 --- /dev/null +++ b/src/components/email/structured-mail/StructuredMailBanner.tsx @@ -0,0 +1,45 @@ +// App only: the booking, parcel and order cards for MessageContent, in one +// component so the reader mounts them with one line (the webmail wires the +// same pieces in email-viewer.tsx). +import React from 'react'; +import type { Email } from '../../../api/types'; +import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../../../lib/trusted-senders'; +import { useHasContacts } from '../../../lib/capabilities'; +import { useContactsStore } from '../../../stores/contacts-store'; +import { useSettingsStore } from '../../../stores/settings-store'; +import { StructuredMailCards } from './structured-mail-cards'; +import { useStructuredMail } from './use-structured-mail'; + +interface Props { + email: Email; + /** Account the message lives in (a shared mailbox's owner); undefined for the user's own. */ + jmapAccountId?: string; +} + +/** + * Cards from the message's schema.org markup and Wallet passes, for a sender + * the receiving server verified. Nothing for any other message. Product + * images load only when the body's remote content would load without a tap: + * the policy allows it or the sender is trusted. + */ +export function StructuredMailBanner({ email, jmapAccountId }: Props) { + const cards = useStructuredMail(email, jmapAccountId); + const policy = useSettingsStore((s) => s.externalContentPolicy); + const trustedSenders = useSettingsStore((s) => s.trustedSenders); + const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted); + const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); + const trustedBookEmails = useContactsStore((s) => s.trustedSenderEmails); + const hasContacts = useHasContacts(); + const sender = email.from?.[0]?.email; + const allowRemoteImages = React.useMemo( + () => policy === 'allow' || isSenderContentTrusted(sender, { + isLocallyTrusted: isSenderTrusted, + syncEnabled: isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts), + trustedBookEmails, + }), + // trustedSenders changes what isSenderTrusted answers. + [policy, sender, isSenderTrusted, trustedSenders, trustedSendersAddressBook, hasContacts, trustedBookEmails], // eslint-disable-line react-hooks/exhaustive-deps + ); + if (cards.length === 0) return null; + return ; +} diff --git a/src/components/email/structured-mail/__tests__/render-native.ts b/src/components/email/structured-mail/__tests__/render-native.ts new file mode 100644 index 00000000..ab920999 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/render-native.ts @@ -0,0 +1,347 @@ +// A small renderer for the card tests. The app has no React renderer in its +// test setup (no react-test-renderer, no testing-library), so this walks the +// element tree, calls each function component with a stand-in hook +// dispatcher, and returns the host elements (View, Text, Pressable ...) as +// plain objects. One pass, no commits: state setters and effects do nothing +// here, which is what a snapshot of a card needs. renderHook below keeps +// state and runs effects, for hooks. +import React from 'react'; +import { vi } from 'vitest'; + +type Internals = { H: unknown }; +const internals = (React as unknown as { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: Internals }) + .__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + +export interface HostNode { + type: string; + props: Record; + children: Array; +} + +const MEMO = Symbol.for('react.memo'); +const FORWARD_REF = Symbol.for('react.forward_ref'); +const FRAGMENT = Symbol.for('react.fragment'); + +const oneShotDispatcher = { + useState: (init: unknown) => [typeof init === 'function' ? (init as () => unknown)() : init, () => undefined], + useReducer: (_r: unknown, init: unknown, fn?: (a: unknown) => unknown) => [fn ? fn(init) : init, () => undefined], + useMemo: (fn: () => unknown) => fn(), + useCallback: (fn: unknown) => fn, + useRef: (current: unknown) => ({ current }), + useEffect: () => undefined, + useLayoutEffect: () => undefined, + useInsertionEffect: () => undefined, + useImperativeHandle: () => undefined, + useContext: (context: { _currentValue: unknown }) => context._currentValue, + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(), + useDebugValue: () => undefined, + useId: () => ':r:', + useTransition: () => [false, (fn: () => void) => fn()], + useDeferredValue: (value: unknown) => value, +}; + +function renderNode(node: unknown, out: Array): void { + if (node === null || node === undefined || typeof node === 'boolean') return; + if (typeof node === 'string' || typeof node === 'number') { + out.push(String(node)); + return; + } + if (Array.isArray(node)) { + for (const child of node) renderNode(child, out); + return; + } + const element = node as { type: any; props: Record }; + const { type, props } = element; + if (type === FRAGMENT) { + renderNode(props.children, out); + return; + } + if (typeof type === 'string') { + const children: Array = []; + renderNode(props.children, children); + out.push({ type, props, children }); + return; + } + if (type && type.$$typeof === MEMO) { + renderNode({ type: type.type, props }, out); + return; + } + if (type && type.$$typeof === FORWARD_REF) { + renderNode(callComponent(() => type.render(props, null)), out); + return; + } + if (typeof type === 'function') { + if (type.prototype?.isReactComponent) throw new Error('class components are not supported'); + renderNode(callComponent(() => type(props)), out); + return; + } + throw new Error(`cannot render ${String(type)}`); +} + +function callComponent(fn: () => unknown): unknown { + const previous = internals.H; + internals.H = oneShotDispatcher; + try { + return fn(); + } finally { + internals.H = previous; + } +} + +/** Render `element` once into host nodes; the root holds them as children. */ +export function render(element: React.ReactElement): HostNode { + const children: Array = []; + renderNode(element, children); + return { type: 'root', props: {}, children }; +} + +/** Every host node below `node` (itself included) that `test` accepts, in tree order. */ +export function findAll(node: HostNode, test: (n: HostNode) => boolean): HostNode[] { + const out: HostNode[] = []; + const walk = (n: HostNode) => { + if (test(n)) out.push(n); + for (const child of n.children) if (typeof child !== 'string') walk(child); + }; + walk(node); + return out; +} + +export function byTestId(node: HostNode, id: string): HostNode[] { + return findAll(node, (n) => n.props.testID === id); +} + +export function oneByTestId(node: HostNode, id: string): HostNode { + const found = byTestId(node, id); + if (found.length !== 1) throw new Error(`expected one ${id}, found ${found.length}`); + return found[0]; +} + +export function byLabel(node: HostNode, label: string | RegExp): HostNode[] { + return findAll(node, (n) => typeof n.props.accessibilityLabel === 'string' + && (typeof label === 'string' ? n.props.accessibilityLabel === label : label.test(n.props.accessibilityLabel))); +} + +/** The text a node shows, its strings joined, Unicode isolates removed. */ +export function textOf(node: HostNode | string): string { + const raw = typeof node === 'string' ? node : node.children.map(textOf).join(''); + return raw.replace(/[⁦-⁩]/g, ''); +} + +/** The text with its isolates, to check direction marks. */ +export function rawTextOf(node: HostNode | string): string { + return typeof node === 'string' ? node : node.children.map(rawTextOf).join(''); +} + +/** Flattened style of a host node. */ +export function styleOf(node: HostNode, state: { pressed?: boolean } = {}): Record { + const flatten = (style: unknown): Record => { + if (!style) return {}; + if (typeof style === 'function') return flatten(style({ pressed: !!state.pressed })); + if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten)); + return style as Record; + }; + return flatten(node.props.style); +} + +// --------------------------------------------------------------------------- +// hooks + +function depsChanged(prev: unknown[] | undefined, next: unknown[] | undefined): boolean { + if (!prev || !next) return true; + return prev.length !== next.length || prev.some((value, i) => !Object.is(value, next[i])); +} + +export interface HookResult { + readonly current: T; + rerender: () => void; + unmount: () => void; + renders: () => number; +} + +/** + * Run `hook` like a component would: state survives renders, a state change + * or store update renders again, effects run after each render and clean up + * on deps change and unmount. + */ +export function renderHook(hook: () => T): HookResult { + const slots: any[] = []; + let index = 0; + let result: T; + let rendering = false; + let dirty = false; + let mounted = true; + let count = 0; + const pending: Array<() => void> = []; + + const schedule = () => { + if (!mounted) return; + if (rendering) { + dirty = true; + return; + } + run(); + }; + + const effect = (fn: () => void | (() => void), deps?: unknown[]) => { + const i = index++; + const prev = slots[i] as { deps?: unknown[]; cleanup?: () => void } | undefined; + if (prev && deps && !depsChanged(prev.deps, deps)) return; + pending.push(() => { + prev?.cleanup?.(); + const cleanup = fn(); + slots[i] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined }; + }); + if (!prev) slots[i] = { deps: undefined }; + }; + + const dispatcher = { + useState: (init: unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = { value: typeof init === 'function' ? (init as () => unknown)() : init }; + const slot = slots[i]; + if (!slot.set) { + slot.set = (value: unknown) => { + const next = typeof value === 'function' ? (value as (v: unknown) => unknown)(slot.value) : value; + if (Object.is(next, slot.value)) return; + slot.value = next; + schedule(); + }; + } + return [slot.value, slot.set]; + }, + useReducer: (reducer: (s: unknown, a: unknown) => unknown, init: unknown, fn?: (a: unknown) => unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = { value: fn ? fn(init) : init }; + const slot = slots[i]; + slot.dispatch ??= (action: unknown) => { + const next = reducer(slot.value, action); + if (Object.is(next, slot.value)) return; + slot.value = next; + schedule(); + }; + return [slot.value, slot.dispatch]; + }, + useMemo: (fn: () => unknown, deps?: unknown[]) => { + const i = index++; + const prev = slots[i]; + if (prev && deps && !depsChanged(prev.deps, deps)) return prev.value; + const value = fn(); + slots[i] = { deps, value }; + return value; + }, + useCallback: (fn: unknown, deps?: unknown[]) => dispatcher.useMemo(() => fn, deps), + useRef: (current: unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = { current }; + return slots[i]; + }, + useEffect: effect, + useLayoutEffect: effect, + useInsertionEffect: effect, + useImperativeHandle: () => { index++; }, + useContext: (context: { _currentValue: unknown }) => context._currentValue, + useSyncExternalStore: (subscribe: (cb: () => void) => () => void, getSnapshot: () => unknown) => { + const i = index++; + const value = getSnapshot(); + if (!slots[i]) { + const slot = { value, unsubscribe: () => undefined as void }; + slots[i] = slot; + slot.unsubscribe = subscribe(() => { + const next = getSnapshot(); + if (Object.is(next, slot.value)) return; + slot.value = next; + schedule(); + }); + } else { + slots[i].value = value; + } + return value; + }, + useDebugValue: () => undefined, + useId: () => `:r${index++}:`, + useTransition: () => [false, (fn: () => void) => fn()], + useDeferredValue: (value: unknown) => value, + }; + + function run() { + let loops = 0; + do { + if (++loops > 50) throw new Error('renderHook: too many re-renders (a selector that returns a new object?)'); + dirty = false; + rendering = true; + index = 0; + const previous = internals.H; + internals.H = dispatcher; + try { + result = hook(); + count++; + } finally { + internals.H = previous; + rendering = false; + } + while (pending.length > 0) { + rendering = true; + try { + pending.shift()!(); + } finally { + rendering = false; + } + } + } while (dirty); + } + + run(); + return { + get current() { return result; }, + rerender: () => run(), + unmount: () => { + mounted = false; + for (const slot of slots) { + slot?.cleanup?.(); + slot?.unsubscribe?.(); + } + }, + renders: () => count, + }; +} + +/** Wait until `check` passes (it throws until then), flushing promises and timers in between. */ +export async function waitFor(check: () => void, { timeout = 2000 } = {}): Promise { + const started = Date.now(); + let last: unknown; + for (;;) { + try { + check(); + return; + } catch (err) { + last = err; + } + if (Date.now() - started > timeout) throw last; + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** The react-native surface the cards use, as host components. */ +export function reactNativeMock() { + const host = (name: string) => name; + return { + View: host('View'), + Text: host('Text'), + Pressable: host('Pressable'), + Image: host('Image'), + ActivityIndicator: host('ActivityIndicator'), + ScrollView: host('ScrollView'), + Modal: host('Modal'), + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1, flatten: (s: unknown) => s }, + Platform: { OS: 'android', Version: 33, select: (spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios }, + I18nManager: { isRTL: false, allowRTL: () => undefined, forceRTL: () => undefined }, + Alert: { alert: vi.fn() }, + Linking: { openURL: vi.fn(async () => undefined) }, + Appearance: { getColorScheme: () => 'light', addChangeListener: () => ({ remove: () => undefined }) }, + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) }, + useColorScheme: () => 'light', + useWindowDimensions: () => ({ width: 400, height: 800, scale: 2, fontScale: 1 }), + NativeModules: {}, + NativeEventEmitter: class { addListener() { return { remove: () => undefined }; } removeAllListeners() {} }, + }; +} diff --git a/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts b/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts new file mode 100644 index 00000000..9f393f70 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts @@ -0,0 +1,57 @@ +// The one-line mount for MessageContent: cards for a verified sender's +// booking or order mail, nothing for anything else, and product images only +// when the body's remote content would load. +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock()); +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })); +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(async () => true) })); + +import type { Email } from '../../../../api/types'; +import { useSettingsStore } from '../../../../stores/settings-store'; +import { StructuredMailBanner } from '../StructuredMailBanner'; +import { byTestId, render, textOf } from './render-native'; + +const ORDER = { + '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', + orderedItem: { '@type': 'Product', name: 'Boots', image: 'https://img.shop.example/boots.jpg' }, +}; +const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example'; +const RECEIVED = 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1'; + +function mail(ar: string | null, subject = 'Your order'): Email { + return { + id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false, + receivedAt: '2026-10-30T09:00:00Z', subject, from: [{ email: 'orders@shop.example' }], + headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { '1': { value: `

Thanks

` } }, + }; +} + +const initialSettings = useSettingsStore.getState(); +afterEach(() => useSettingsStore.setState(initialSettings, true)); + +describe('StructuredMailBanner', () => { + it('shows the order card of a verified mail', () => { + const tree = render(createElement(StructuredMailBanner, { email: mail(PASS) })); + const [card] = byTestId(tree, 'structured-mail-card-shipment'); + expect(textOf(card)).toContain('Order number'); + expect(textOf(card)).toContain('SO-1'); + }); + + it('shows nothing for an unverified sender or a forward', () => { + expect(render(createElement(StructuredMailBanner, { email: mail(null) })).children).toEqual([]); + expect(render(createElement(StructuredMailBanner, { email: mail(PASS, 'Fwd: Your order') })).children).toEqual([]); + }); + + it('loads product images only when remote content would load', () => { + useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] }); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0); + useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] }); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1); + useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] }); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/structured-mail-cards.test.ts b/src/components/email/structured-mail/__tests__/structured-mail-cards.test.ts new file mode 100644 index 00000000..4ed9a366 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/structured-mail-cards.test.ts @@ -0,0 +1,329 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// structured-mail-cards.test.tsx at 972e5f621, on the app's views: the same +// cards from the reviewed fixtures, read back from the rendered tree. The +// strings are the English fallbacks (the vendored catalog predates these +// keys), so the assertions read like the cards do. +import { createElement } from 'react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock()); +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })); +const setStringAsync = vi.fn(async (_value: string) => true); +vi.mock('expo-clipboard', () => ({ setStringAsync: (value: string) => setStringAsync(value) })); +const toastSuccess = vi.fn(); +vi.mock('../../../../stores/toast-store', () => ({ toast: { success: (m: string) => toastSuccess(m), error: vi.fn() } })); +const openExternalUrl = vi.fn(async (_url: string) => true); +vi.mock('../../../../lib/open-url', () => ({ openExternalUrl: (url: string) => openExternalUrl(url) })); + +import { I18nManager } from 'react-native'; +import expectedCards from '../../../../lib/structured-mail/__tests__/fixtures/expected-cards.json'; +import type { Booking, Shipment, StructuredCard, TransportBooking } from '../../../../lib/structured-mail/types'; +import { useSettingsStore } from '../../../../stores/settings-store'; +import { StructuredMailCards, type StructuredMailCardsProps } from '../structured-mail-cards'; +import { byLabel, byTestId, findAll, oneByTestId, rawTextOf, render, styleOf, textOf, type HostNode } from './render-native'; + +const fixtures = expectedCards as unknown as Record; +const card = (id: string, index = 0) => structuredClone(fixtures[id][index]) as T; +const initialSettings = useSettingsStore.getState(); + +beforeAll(() => { + // Only Date is faked: "now" decides whether a year is shown. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-30T12:00:00Z')); +}); +afterAll(() => { + vi.useRealTimers(); +}); +beforeEach(() => { + useSettingsStore.setState({ timeFormat: '24h', calendarTimeZone: 'Europe/Berlin' }); +}); +afterEach(() => { + useSettingsStore.setState(initialSettings, true); + setStringAsync.mockClear(); + toastSuccess.mockClear(); + openExternalUrl.mockClear(); + (I18nManager as { isRTL: boolean }).isRTL = false; +}); + +function show(cards: StructuredCard[], extra: Partial = {}): HostNode { + return render(createElement(StructuredMailCards, { cards, allowRemoteImages: false, ...extra })); +} + +/** The card with this accessible name (one of them). */ +function region(tree: HostNode, name: string, index = 0): HostNode { + const found = findAll(tree, (n) => n.type === 'View' && n.props.accessibilityLabel === name && /^structured-mail-card-/.test(n.props.testID ?? '')); + if (!found[index]) throw new Error(`no card named ${name}`); + return found[index]; +} + +const title = (node: HostNode) => oneByTestId(node, 'structured-mail-title'); +const pressables = (node: HostNode) => findAll(node, (n) => n.type === 'Pressable'); + +describe('StructuredMailCards', () => { + it('renders nothing for an empty list', () => { + expect(show([]).children).toEqual([]); + }); + + it('shows a flight with its route, legs at local time, passengers and seats', () => { + const r = region(show([card('flight-two-passengers-two-legs')]), 'Flight booking'); + expect(title(r).props.accessibilityLabel).toBe('FRA to JFK to FRA'); + expect(textOf(title(r))).toBe('FRA → JFK → FRA'); + expect(textOf(r)).toContain('Flight · Lufthansa'); + const legs = byTestId(r, 'structured-mail-leg'); + expect(legs).toHaveLength(2); + // The mail's wall clock at each airport: 10:15 in Frankfurt, 13:05 in New York. + expect(textOf(legs[0])).toContain('Lufthansa LH 400'); + expect(textOf(legs[0])).toContain('Tue, Dec 1, 10:15 · Frankfurt Airport (FRA)'); + expect(textOf(legs[0])).toContain('13:05 local time · John F. Kennedy International Airport (JFK)'); + // Overnight return: the arrival date is written out. + expect(textOf(legs[1])).toContain('Tue, Dec 8, 17:30'); + expect(textOf(legs[1])).toContain('Wed, Dec 9, 07:20 · Frankfurt Airport (FRA)'); + expect(textOf(legs[0])).toContain('Seats: Erika Mustermann 12A, Max Mustermann 12B'); + expect(textOf(r)).toContain('Erika Mustermann, Max Mustermann'); + }); + + it('marks only the times whose place keeps another clock than the reader', () => { + const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg'); + // Reader in Berlin: Frankfurt needs no mark, New York does. + expect(byTestId(outbound, 'local-time-hint')).toHaveLength(1); + }); + + it('never shifts the time to the reader zone', () => { + useSettingsStore.setState({ calendarTimeZone: 'Asia/Tokyo' }); + const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg'); + expect(textOf(outbound)).toContain('Tue, Dec 1, 10:15'); + expect(textOf(outbound)).toContain('13:05'); + expect(byTestId(outbound, 'local-time-hint')).toHaveLength(2); + }); + + it('writes times on the 12-hour clock when the user picked it', () => { + useSettingsStore.setState({ timeFormat: '12h' }); + const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg'); + expect(textOf(outbound)).toMatch(/Tue, Dec 1, 10:15\sAM/); + }); + + it('copies the booking reference', async () => { + const tree = show([card('flight-two-passengers-two-legs')]); + const [chip] = byLabel(tree, 'Copy booking reference QX7K2M'); + expect(chip.type).toBe('Pressable'); + chip.props.onPress(); + await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('QX7K2M')); + await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Booking reference copied')); + }); + + it('shows the check-in link with its host and opens it through the URL guard', () => { + const tree = show([card('flight-two-passengers-two-legs')]); + const [link] = byTestId(tree, 'structured-mail-link'); + expect(link.props.accessibilityRole).toBe('link'); + expect(textOf(link)).toBe('Check in· airline-a.example.com'); + link.props.onPress(); + expect(openExternalUrl).toHaveBeenCalledWith('https://www.airline-a.example.com/checkin?pnr=QX7K2M'); + }); + + it('never renders a link that is not http(s)', () => { + const flight = card('flight-two-passengers-two-legs'); + flight.links = { checkin: { url: 'javascript:alert(1)', host: 'evil.example' }, manage: { url: 'data:text/html,x', host: 'x' } }; + const tree = show([flight]); + expect(byTestId(tree, 'structured-mail-link')).toHaveLength(0); + expect(JSON.stringify(textOf(tree))).not.toContain('javascript:'); + }); + + it('shows a cancelled booking struck through with a Cancelled pill', () => { + const r = region(show([card('flight-cancelled')]), 'Flight booking'); + expect(byTestId(r, 'structured-mail-pill').map(textOf)).toEqual(['Cancelled']); + expect(styleOf(title(r)).textDecorationLine).toBe('line-through'); + }); + + it('marks a single cancelled leg of a live booking', () => { + const flight = card('flight-two-passengers-two-legs'); + flight.legs[1].status = 'cancelled'; + const tree = show([flight]); + expect(byTestId(tree, 'structured-mail-leg')).toHaveLength(1); + const [cancelled] = byTestId(tree, 'structured-mail-leg-cancelled'); + expect(byTestId(cancelled, 'structured-mail-pill').map(textOf)).toEqual(['Cancelled']); + expect(styleOf(title(tree)).textDecorationLine).toBeUndefined(); + }); + + it('shows Pending and On hold', () => { + const pending = card('train-multiple-types'); + pending.status = 'pending'; + const hold = card('graph-hotel'); + hold.status = 'hold'; + const tree = show([pending, hold]); + expect(textOf(region(tree, 'Train booking'))).toContain('Pending'); + expect(textOf(region(tree, 'Hotel booking'))).toContain('On hold'); + }); + + it('titles a train with its towns and lists the stations', () => { + const r = region(show([card('train-multiple-types')]), 'Train booking'); + expect(title(r).props.accessibilityLabel).toBe('Hamburg to München'); + expect(textOf(r)).toContain('Wed, Nov 18, 08:01 · Hamburg Hbf'); + expect(textOf(r)).toContain('13:58 · München Hbf'); + expect(textOf(r)).toContain('Rail Co ICE 577'); + }); + + it('shows a hotel stay with address, check-in and check-out times, nights and the manage link', () => { + const r = region(show([card('graph-hotel')]), 'Hotel booking'); + expect(textOf(title(r))).toBe('Hotel Am Markt'); + expect(textOf(r)).toContain('Marktplatz 1, 10178 Berlin'); + expect(textOf(r)).toContain('Check-in Fri, Dec 4, from 15:00'); + expect(textOf(r)).toContain('Check-out Sun, Dec 6, until 11:00'); + expect(textOf(r)).toContain('2 nights'); + expect(textOf(byLabel(r, /^Manage booking, /)[0])).toContain('hotel-booking.example.com'); + }); + + it('shows a dates-only stay without times', () => { + const r = region(show([card('hotel-dates-only')]), 'Hotel booking'); + expect(textOf(r)).toContain('Check-in Tue, Jun 1, 2027'); + expect(textOf(r)).toContain('Check-out Tue, Jun 8, 2027'); + expect(textOf(r)).not.toContain('from '); + expect(textOf(r)).toContain('7 nights'); + }); + + it('shows a rental car with pick-up, return and car', () => { + const r = region(show([card('rental-car')]), 'Rental car booking'); + expect(textOf(title(r))).toBe('Car Rental Co'); + expect(textOf(r)).toContain('Pick-up Fri, Dec 4, 09:00 · Flughafen BER, Schönefeld'); + expect(textOf(r)).toContain('Return Mon, Dec 7, 18:00 · Flughafen BER, Schönefeld'); + expect(textOf(r)).toContain('VW Golf'); + }); + + it('shows an event with its times, venue, tickets and ticket link', () => { + const tree = show([card('eventbrite-trailing-commas'), card('ahoi-event')]); + const eventbrite = region(tree, 'Event booking', 0); + const workshop = region(tree, 'Event booking', 1); + expect(textOf(title(eventbrite))).toBe('Jazz im Park'); + expect(textOf(eventbrite)).toContain('Sat, Nov 14, 19:00 – 22:30'); + expect(textOf(eventbrite)).toContain('Stadtpark, Parkweg 2, Hamburg'); + expect(textOf(byLabel(eventbrite, /^Ticket, /)[0])).toContain('tickets.example.com'); + expect(textOf(workshop)).toContain('Sun, Jul 18, 2021, 14:00'); + expect(textOf(workshop)).toContain('Tickets: AB12CD-1'); + }); + + it('shows a restaurant table with time, address and party size', () => { + const r = region(show([card('restaurant-cafe')]), 'Restaurant reservation'); + expect(textOf(title(r))).toBe('Café Kranzler'); + expect(textOf(r)).toContain('Sat, Nov 7, 19:30'); + expect(textOf(r)).toContain('Kurfürstendamm 18, 10719 Berlin'); + expect(textOf(r)).toContain('Table for 4'); + }); + + it('shows an order with its parcel: number, status, items, total, tracking and links', async () => { + const r = region(show([card('order-and-parcel')]), 'Order and delivery'); + expect(textOf(title(r))).toBe('Outdoor Shop'); + expect(textOf(r)).toContain('Order · DHL'); + expect(byTestId(r, 'structured-mail-pill').map(textOf)).toEqual(['Shipped']); + expect(textOf(r)).toContain('Wanderschuhe, Socken'); + expect(textOf(oneByTestId(r, 'structured-mail-summary'))).toBe('3 items · Total €129.90'); + expect(textOf(r)).toContain('Arrives by Tue, Nov 3'); + expect(textOf(byLabel(r, /^Track, /)[0])).toBe('Track· dhl.de'); + expect(textOf(byLabel(r, /^View order, /)[0])).toContain('outdoor-shop.example.com'); + + byLabel(r, 'Copy tracking number 00340434161234567890')[0].props.onPress(); + await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('00340434161234567890')); + byLabel(r, 'Copy order number SO-449120')[0].props.onPress(); + await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('SO-449120')); + }); + + it('drops the expected date once the order is delivered', () => { + const shipment = card('order-and-parcel'); + shipment.status = 'delivered'; + const r = region(show([shipment]), 'Order and delivery'); + expect(textOf(r)).toContain('Delivered'); + expect(textOf(r)).not.toContain('Arrives by'); + // Carrier and tracking number stay. + expect(textOf(r)).toContain('00340434161234567890'); + }); + + describe('right-to-left place names and values', () => { + const hebrewTrain = (): TransportBooking => { + const train = card('train-multiple-types'); + train.legs[0].departure.place = { name: 'תל אביב סבידור מרכז', address: { locality: 'תל אביב', country: 'IL' } }; + train.legs[0].arrival!.place = { name: 'חיפה מרכז השמונה', address: { locality: 'חיפה', country: 'IL' } }; + return train; + }; + const FSI = String.fromCharCode(0x2068); + const LRI = String.fromCharCode(0x2066); + const PDI = String.fromCharCode(0x2069); + + it.each([false, true])('isolates each stop and points the arrow the reading direction (RTL %s)', (rtl) => { + (I18nManager as { isRTL: boolean }).isRTL = rtl; + const r = region(show([hebrewTrain()]), 'Train booking'); + // Spoken in reading order, whatever the script. + expect(title(r).props.accessibilityLabel).toBe('תל אביב to חיפה'); + const arrow = rtl ? '←' : '→'; + expect(rawTextOf(title(r))).toBe(`${FSI}תל אביב${PDI} ${arrow} ${FSI}חיפה${PDI}`); + // Station names in the leg rows are isolated too. + const leg = oneByTestId(r, 'structured-mail-leg'); + expect(rawTextOf(leg)).toContain(`${FSI}תל אביב סבידור מרכז${PDI}`); + expect(rawTextOf(leg)).toContain(`${FSI}חיפה מרכז השמונה${PDI}`); + }); + + it('keeps references, tracking and order numbers, seats and tickets left to right', () => { + (I18nManager as { isRTL: boolean }).isRTL = true; + const tree = show([card('flight-two-passengers-two-legs'), card('ahoi-event'), card('order-and-parcel')]); + const all = rawTextOf(tree); + for (const value of ['QX7K2M', 'AB12CD', 'SO-449120', '00340434161234567890']) { + expect(all).toContain(`${LRI}${value}${PDI}`); + } + const seats = byTestId(tree, 'structured-mail-seats').map(rawTextOf).join(' '); + for (const seat of ['12A', '12B', '31K', '31J']) expect(seats).toContain(`${LRI}${seat}${PDI}`); + expect(rawTextOf(oneByTestId(tree, 'structured-mail-tickets'))).toContain(`${LRI}AB12CD-1${PDI}`); + }); + }); + + it('shows no action row when a card has nothing it would put there', () => { + const restaurant = card('restaurant-cafe'); + delete restaurant.reference; + // A restaurant card shows no ticket link, so this alone gives no row. + restaurant.links = { ticket: { url: 'https://tables.example.com/t/1', host: 'tables.example.com' } }; + const r = region(show([restaurant]), 'Restaurant reservation'); + expect(byTestId(r, 'structured-mail-actions')).toHaveLength(0); + expect(byTestId(r, 'structured-mail-link')).toHaveLength(0); + }); + + it('shows one Track button per tracking page', () => { + const shipment = card('order-and-parcel'); + const parcel = shipment.parcels[0]; + // The same parcel twice (same number and page), and a second one with its own page. + shipment.parcels = [parcel, { ...parcel }, { ...parcel, trackingNumber: '00340434169999999999', trackingUrl: { url: 'https://www.dhl.de/x?piececode=00340434169999999999', host: 'dhl.de' } }]; + const r = region(show([shipment]), 'Order and delivery'); + const tracks = byLabel(r, /^Track, /); + tracks.forEach((track) => track.props.onPress()); + expect(openExternalUrl.mock.calls.map(([url]) => url)).toEqual([parcel.trackingUrl!.url, 'https://www.dhl.de/x?piececode=00340434169999999999']); + }); + + it('lists three items and says how many more there are', () => { + const shipment: Shipment = { + kind: 'shipment', key: 's', merchant: 'Shop', orderNumber: 'A-1', source: 'json-ld', parcels: [], + items: ['One', 'Two', 'Three', 'Four', 'Five'].map((name) => ({ name })), + total: { amount: '1299.00', currency: 'USD' }, + }; + const r = region(show([shipment]), 'Order and delivery'); + expect(textOf(r)).toContain('One, Two, Three and 2 more'); + expect(textOf(r)).not.toContain('Four'); + expect(textOf(r)).toContain('Total $1,299.00'); + }); + + it('loads item images only when remote content is allowed', () => { + expect(byTestId(show([card('order-and-parcel')]), 'structured-mail-item-image')).toHaveLength(0); + const images = byTestId(show([card('order-and-parcel')], { allowRemoteImages: true }), 'structured-mail-item-image'); + expect(images).toHaveLength(1); + expect(images[0].props.source).toEqual({ uri: 'https://img.shop.example.com/p/1.jpg' }); + }); + + it('gives each booking to the calendar slot, and none for a shipment', () => { + const slot = vi.fn((booking: Booking) => createElement('Text', { testID: 'slot' }, `add ${booking.kind}`)); + const tree = show([card('flight-two-passengers-two-legs'), card('graph-hotel'), card('order-and-parcel')], { calendarActions: slot }); + expect(slot).toHaveBeenCalledTimes(2); + expect(slot.mock.calls.map(([b]) => b.kind)).toEqual(['flight', 'lodging']); + expect(slot.mock.calls[0][0]).toMatchObject({ reference: 'QX7K2M' }); + expect(byTestId(region(tree, 'Flight booking'), 'slot').map(textOf)).toEqual(['add flight']); + }); + + it('renders no calendar items in the action row when the slot is undefined', () => { + const r = region(show([card('restaurant-cafe')]), 'Restaurant reservation'); + // Only the reference chip. + expect(pressables(r)).toHaveLength(1); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/use-structured-mail.test.ts b/src/components/email/structured-mail/__tests__/use-structured-mail.test.ts new file mode 100644 index 00000000..6404ab93 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/use-structured-mail.test.ts @@ -0,0 +1,81 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// use-structured-mail.test.tsx at ba6d5e448. App: messages carry raw +// headers only, and the gate is the app's (on until an org policy exists), +// switched here through a mock. +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const gate = vi.hoisted(() => ({ on: true })); +vi.mock('../../../../lib/structured-mail/gate', () => ({ + isStructuredMailEnabled: () => gate.on, + useStructuredMailEnabled: () => gate.on, +})); + +import type { Email } from '../../../../api/types'; +import { CARD_SOURCES } from '../sources'; +import { useStructuredMail } from '../use-structured-mail'; +import { renderHook, waitFor } from './render-native'; + +const ld = (value: unknown) => ``; +const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' }; +const FLIGHT = { + '@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'Flight', flightNumber: '400', airline: { name: 'Lufthansa', iataCode: 'LH' }, + departureAirport: { iataCode: 'FRA' }, departureTime: '2026-12-01T10:15:00+01:00', + arrivalAirport: { iataCode: 'JFK' }, arrivalTime: '2026-12-01T13:05:00-05:00', + }, +}; +const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example'; +const RECEIVED = 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1'; + +function mail(id: string, body: string, ar: string | null = PASS): Email { + return { + id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false, + receivedAt: '2026-10-30T09:00:00Z', subject: 'Booking', + from: [{ email: 'orders@shop.example' }], + headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { '1': { value: `${body}`, isTruncated: false } }, + }; +} + +afterEach(() => { + gate.on = true; + CARD_SOURCES.length = 0; +}); + +describe('useStructuredMail', () => { + it('returns the cards of a trusted message', () => { + const hook = renderHook(() => useStructuredMail(mail('a', ld(ORDER)))); + expect(hook.current).toMatchObject([{ kind: 'shipment', orderNumber: 'SO-1' }]); + }); + + it('returns nothing for an untrusted message or with the gate off', () => { + expect(renderHook(() => useStructuredMail(mail('b', ld(ORDER), null))).current).toEqual([]); + gate.on = false; + expect(renderHook(() => useStructuredMail(mail('c', ld(ORDER)))).current).toEqual([]); + }); + + it('shows a flight only once the airport zones are resolved', async () => { + const hook = renderHook(() => useStructuredMail(mail('d', ld(FLIGHT)))); + expect(hook.current).toEqual([]); + await waitFor(() => expect(hook.current).toHaveLength(1)); + expect(hook.current[0]).toMatchObject({ kind: 'flight', legs: [{ departure: { at: { zone: 'Europe/Berlin' } } }] }); + }); + + it('merges cards from attachment sources, and never asks them about untrusted mail', async () => { + let asked = 0; + const accounts: (string | undefined)[] = []; + CARD_SOURCES.push(async ({ accountId }) => { + asked++; + accounts.push(accountId); + return [{ kind: 'shipment', key: 'shipment|order:so-2', orderNumber: 'SO-2', merchant: 'Shop', items: [], parcels: [], source: 'pkpass' }]; + }); + const hook = renderHook(() => useStructuredMail(mail('e', ld(ORDER)), 'shared-acc')); + await waitFor(() => expect(hook.current).toHaveLength(2)); + renderHook(() => useStructuredMail(mail('f', ld(ORDER), null))); + expect(asked).toBe(1); + // The blobs are fetched from the account the message lives in. + expect(accounts).toEqual(['shared-acc']); + }); +}); diff --git a/src/components/email/structured-mail/booking-cards.tsx b/src/components/email/structured-mail/booking-cards.tsx new file mode 100644 index 00000000..992a7ea2 --- /dev/null +++ b/src/components/email/structured-mail/booking-cards.tsx @@ -0,0 +1,360 @@ +// Port of webmail components/email/structured-mail/booking-cards.tsx at +// b0782af2d (same cards and rows, React Native views). App difference: the +// route title is one line of text with isolated stops and an arrow that +// points the reading direction, where the webmail draws arrow icons. +import React from 'react'; +import { I18nManager, Text, View } from 'react-native'; +import { ArrowRight, Clock, MapPin, Phone, Users } from '../../icons'; +import { + formatAddress, + formatMomentDate, + formatMomentTime, + formatPlace, + isOtherZone, + isWebLink, + legPlaceLabel, + legService, + nightsBetween, + routeRuns, + routeTitle, + sameLocalDate, +} from '../../../lib/structured-mail/format'; +import type { + Booking, + EventBooking, + Link, + LodgingBooking, + Moment, + RentalCarBooking, + RestaurantBooking, + TransportBooking, + TransportLeg, +} from '../../../lib/structured-mail/types'; +import { useLocaleStore } from '../../../stores/locale-store'; +import { + BookingStatusPill, + CardLink, + CardShell, + LocalTimeHint, + MetaRow, + Pill, + When, + eyebrowText, + isolate, + ltr, + useCardFormat, + useCardStyles, +} from './card-parts'; +import { CopyValueChip } from './copy-value-chip'; +import { Bed, Bus, Car, Plane, Restaurant, Ticket, Train } from './icons'; + +export interface BookingCardProps { + booking: B; + /** Slot in the action row, for "Add to calendar". */ + calendarActions?: (booking: Booking) => React.ReactNode; +} + +const TRANSPORT_ICON = { flight: Plane, train: Train, bus: Bus } as const; + +interface CardLinkSpec { + link?: Link; + label: string; +} + +/** + * The action row: reference chip, the links this card shows and the + * calendar slot. Undefined when there is none of them, so no empty row. + */ +function bookingActions(booking: Booking, calendarActions: BookingCardProps['calendarActions'], links: CardLinkSpec[]): React.ReactNode { + const shown = links.filter((l) => isWebLink(l.link)); + if (!booking.reference && !calendarActions && shown.length === 0) return undefined; + return ( + + {shown.map((l) => )} + + ); +} + +function BookingActions({ booking, calendarActions, children }: Pick & { children: React.ReactNode }) { + const t = useLocaleStore((s) => s.t); + return ( + <> + {booking.reference ? ( + + ) : null} + {children} + {calendarActions?.(booking)} + + ); +} + +function PassengersRow({ names }: { names: string[] }) { + const t = useLocaleStore((s) => s.t); + if (names.length === 0) return null; + return ( + + {names.map(isolate).join(', ')} + + ); +} + +function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: TransportBooking['kind']; bookingCancelled: boolean }) { + const t = useLocaleStore((s) => s.t); + const { c, styles } = useCardStyles(); + const cancelled = leg.status === 'cancelled'; + const service = legService(leg); + const Icon = TRANSPORT_ICON[kind]; + const arrival = leg.arrival; + + return ( + + {(service || (cancelled && !bookingCancelled)) ? ( + + {service ? ( + + + {service} + + ) : null} + {cancelled && !bookingCancelled ? ( + + ) : null} + + ) : null} + + + · {isolate(legPlaceLabel(leg.departure.place, kind))} + + {arrival ? ( + + {arrival.at ? : null} + {arrival.at ? ' · ' : ''}{isolate(legPlaceLabel(arrival.place, kind))} + + ) : null} + {leg.seats.length > 0 ? ( + + {t('email_viewer.structured_mail.seats', 'Seats')}:{' '} + {leg.seats.map((seat) => `${seat.passenger ? `${isolate(seat.passenger)} ` : ''}${ltr(seat.seat)}`).join(', ')} + + ) : null} + + ); +} + +/** "FRA → JFK → FRA", every stop isolated, the arrow pointing the reading direction. */ +export function routeText(runs: string[][], rtl: boolean): string { + const arrow = rtl ? ' ← ' : ' → '; + return runs.map((run) => run.map(isolate).join(arrow)).join(', '); +} + +export function TransportCard({ booking, calendarActions }: BookingCardProps) { + const t = useLocaleStore((s) => s.t); + const { styles } = useCardStyles(); + const kindLabel = booking.kind === 'flight' + ? t('email_viewer.structured_mail.kind_flight', 'Flight') + : booking.kind === 'train' + ? t('email_viewer.structured_mail.kind_train', 'Train') + : t('email_viewer.structured_mail.kind_bus', 'Bus'); + const name = booking.kind === 'flight' + ? t('email_viewer.structured_mail.region_flight', 'Flight booking') + : booking.kind === 'train' + ? t('email_viewer.structured_mail.region_train', 'Train booking') + : t('email_viewer.structured_mail.region_bus', 'Bus booking'); + const runs = routeRuns(booking); + const title = routeText(runs, I18nManager.isRTL); + const cancelled = booking.status === 'cancelled'; + return ( + run.reduce((from, to) => t('email_viewer.structured_mail.route', '{from} to {to}', { from, to }))).join(', ')} + pills={booking.status && booking.status !== 'confirmed' ? : undefined} + actions={bookingActions(booking, calendarActions, [ + { link: booking.links.checkin, label: t('email_viewer.structured_mail.link_check_in', 'Check in') }, + { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') }, + { link: booking.links.ticket, label: t('email_viewer.structured_mail.link_ticket', 'Ticket') }, + ])} + > + + {booking.legs.map((leg, i) => ( + + ))} + + + + ); +} + +/** "Fri, Dec 4, from 15:00" / "Sun, Dec 6, until 11:00" / the date alone. */ +function StayEnd({ label, at, edge }: { label: string; at: Moment; edge: 'from' | 'until' }) { + const t = useLocaleStore((s) => s.t); + const { styles } = useCardStyles(); + const { opts, readerZone } = useCardFormat(); + const time = formatMomentTime(at, opts); + return ( + + {label}{' '} + {formatMomentDate(at, opts)} + {time ? ( + <> + {', '} + + {edge === 'from' + ? t('email_viewer.structured_mail.from_time', 'from {time}', { time }) + : t('email_viewer.structured_mail.until_time', 'until {time}', { time })} + + {isOtherZone(at, readerZone) ? : null} + + ) : null} + + ); +} + +export function LodgingCard({ booking, calendarActions }: BookingCardProps) { + const t = useLocaleStore((s) => s.t); + const { opts } = useCardFormat(); + const nights = nightsBetween(booking.checkin, booking.checkout); + const address = formatAddress(booking.address, opts.locale); + return ( + : undefined} + actions={bookingActions(booking, calendarActions, [ + { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') }, + { link: booking.links.checkin, label: t('email_viewer.structured_mail.link_check_in', 'Check in') }, + ])} + > + {address ? {address} : null} + + + {nights !== undefined ? ( + {t('email_viewer.structured_mail.nights', '{count, plural, one {# night} other {# nights}}', { count: nights })} + ) : null} + {booking.phone ? {ltr(booking.phone)} : null} + + + ); +} + +export function RentalCarCard({ booking, calendarActions }: BookingCardProps) { + const t = useLocaleStore((s) => s.t); + const title = booking.company?.trim() || t('email_viewer.structured_mail.kind_rentalcar', 'Rental car'); + const pickupPlace = formatPlace(booking.pickup.place); + const dropoffPlace = formatPlace(booking.dropoff.place); + return ( + : undefined} + actions={bookingActions(booking, calendarActions, [ + { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') }, + ])} + > + + {t('email_viewer.structured_mail.pickup', 'Pick-up')}{' '} + + {pickupPlace ? ` · ${pickupPlace}` : null} + + + {t('email_viewer.structured_mail.dropoff', 'Return')}{' '} + + {dropoffPlace ? ` · ${dropoffPlace}` : null} + + {booking.car ? {booking.car} : null} + + + ); +} + +export function EventCard({ booking, calendarActions }: BookingCardProps) { + const t = useLocaleStore((s) => s.t); + const { styles } = useCardStyles(); + const { opts } = useCardFormat(); + const venueName = booking.venue?.name?.trim(); + const venueAddress = formatAddress(booking.venue?.address, opts.locale); + // "Stadtpark, Parkweg 2, Hamburg"; the name alone, or the address alone, when that's all there is. + const venue = venueName && venueAddress + ? (venueAddress.includes(venueName) ? venueAddress : `${venueName}, ${venueAddress}`) + : venueName || venueAddress || formatPlace(booking.venue); + const end = booking.end; + return ( + : undefined} + actions={bookingActions(booking, calendarActions, [ + { link: booking.links.ticket, label: t('email_viewer.structured_mail.link_ticket', 'Ticket') }, + { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') }, + ])} + > + + + {end && (end.time || !sameLocalDate(end, booking.start)) ? ( + <> + {' – '} + + + ) : null} + + {venue ? {venue} : null} + {booking.tickets.length > 0 ? ( + + {t('email_viewer.structured_mail.tickets', 'Tickets')}:{' '} + {ltr(booking.tickets.join(', '))} + + ) : null} + + + ); +} + +export function RestaurantCard({ booking, calendarActions }: BookingCardProps) { + const t = useLocaleStore((s) => s.t); + const { opts } = useCardFormat(); + const address = formatAddress(booking.address, opts.locale); + return ( + : undefined} + actions={bookingActions(booking, calendarActions, [ + { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') }, + ])} + > + + {address ? {address} : null} + {booking.partySize !== undefined && booking.partySize > 0 ? ( + + {t('email_viewer.structured_mail.party_size', '{count, plural, one {Table for #} other {Table for #}}', { count: booking.partySize })} + + ) : null} + + ); +} diff --git a/src/components/email/structured-mail/card-parts.tsx b/src/components/email/structured-mail/card-parts.tsx new file mode 100644 index 00000000..ea1caf60 --- /dev/null +++ b/src/components/email/structured-mail/card-parts.tsx @@ -0,0 +1,260 @@ +// Port of webmail components/email/structured-mail/card-parts.tsx at +// b0782af2d (same parts, React Native views). App differences: links open +// through the URL guard (lib/open-url), the reader's zone is the calendar +// zone setting, and isolation is done with Unicode isolates. +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { ExternalLink, type AppIcon } from '../../icons'; +import { resolveTimeZone } from '../../../lib/calendar-timezone'; +import { openExternalUrl } from '../../../lib/open-url'; +import { formatMoment, formatMomentTime, isOtherZone, isWebLink, type FormatOptions } from '../../../lib/structured-mail/format'; +import type { Link, Moment, ReservationStatus } from '../../../lib/structured-mail/types'; +import { useLocaleStore } from '../../../stores/locale-store'; +import { useSettingsStore } from '../../../stores/settings-store'; +import { useColors } from '../../../theme/colors'; +import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens'; + +/** First strong isolate / left-to-right isolate / pop: what and do. */ +const FSI = String.fromCharCode(0x2068); +const LRI = String.fromCharCode(0x2066); +const PDI = String.fromCharCode(0x2069); + +/** Text that keeps its own direction inside a sentence in the other one (a ). */ +export function isolate(text: string): string { + return `${FSI}${text}${PDI}`; +} + +/** Digits and codes that read left to right in a right-to-left UI (). */ +export function ltr(text: string): string { + return `${LRI}${text}${PDI}`; +} + +export function makeCardStyles(c: ThemePalette) { + return StyleSheet.create({ + list: { gap: spacing.md, marginHorizontal: spacing.lg, marginBottom: spacing.md }, + card: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.md, + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + badge: { + width: 40, height: 40, borderRadius: 20, + alignItems: 'center', justifyContent: 'center', + backgroundColor: c.primaryBg, + }, + badgeCancelled: { backgroundColor: c.errorBg }, + body: { flex: 1, minWidth: 0, gap: spacing.sm }, + eyebrow: { ...typography.small, color: c.textMuted, fontWeight: '600' }, + title: { ...typography.bodySemibold, color: c.text }, + titleCancelled: { color: c.textMuted, textDecorationLine: 'line-through' }, + pills: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 }, + details: { gap: 4 }, + actions: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, paddingTop: 2 }, + metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 }, + metaIcon: { marginTop: 3 }, + metaText: { flex: 1, minWidth: 0, ...typography.body, color: c.textSecondary }, + strong: { color: c.text }, + struck: { textDecorationLine: 'line-through' }, + hint: { ...typography.caption, color: c.textMuted }, + pill: { borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2 }, + pillText: { ...typography.small }, + action: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 6, + minHeight: 36, + maxWidth: '100%', + paddingHorizontal: spacing.md, + paddingVertical: 6, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + }, + actionPressed: { backgroundColor: c.surfaceHover }, + actionText: { ...typography.body, color: c.textSecondary }, + actionHost: { ...typography.body, color: c.textMuted, flexShrink: 1 }, + legs: { gap: spacing.sm }, + leg: { gap: 2 }, + legHead: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: spacing.sm, rowGap: 4 }, + legService: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 }, + legServiceText: { ...typography.bodyMedium, color: c.text, flexShrink: 1 }, + mono: { fontFamily: 'monospace' }, + images: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingTop: 2 }, + image: { width: 40, height: 40, borderRadius: radius.md, borderWidth: 1, borderColor: c.border, backgroundColor: c.background }, + summary: { ...typography.body, color: c.textSecondary, paddingStart: 20 }, + }); +} + +export type CardStyles = ReturnType; + +/** The palette and the card styles for it. */ +export function useCardStyles(): { c: ThemePalette; styles: CardStyles } { + const c = useColors(); + const styles = React.useMemo(() => makeCardStyles(c), [c]); + return { c, styles }; +} + +/** Locale, clock and the reader's zone, for formatting a card's times. */ +export function useCardFormat(): { opts: FormatOptions; readerZone: string } { + const locale = useLocaleStore((s) => s.locale); + const timeFormat = useSettingsStore((s) => s.timeFormat); + const timeZone = useSettingsStore((s) => s.calendarTimeZone); + return { + opts: { locale, hour12: timeFormat === '12h' ? true : timeFormat === '24h' ? false : undefined }, + readerZone: resolveTimeZone(timeZone), + }; +} + +/** + * A time as the mail gave it, at the place, as nested text for a MetaRow. + * `timeOnly` leaves out the date (an arrival on the departure day). Marked + * "local time" when the place's clock differs from the reader's at that + * instant. + */ +export function When({ at, timeOnly = false, struck = false }: { at: Moment; timeOnly?: boolean; struck?: boolean }) { + const { styles } = useCardStyles(); + const { opts, readerZone } = useCardFormat(); + const text = timeOnly && at.time ? formatMomentTime(at, opts) : formatMoment(at, opts); + const other = isOtherZone(at, readerZone); + return ( + <> + {text} + {other && } + + ); +} + +/** The "local time" mark after a time written at the place. */ +export function LocalTimeHint() { + const t = useLocaleStore((s) => s.t); + const { styles } = useCardStyles(); + return ( + + {' '}{t('email_viewer.structured_mail.local_time', 'local time')} + + ); +} + +/** One meta row: a small icon and text that wraps instead of overflowing. */ +export function MetaRow({ icon: Icon, children, testID }: { icon: AppIcon; children: React.ReactNode; testID?: string }) { + const { c, styles } = useCardStyles(); + return ( + + + {children} + + ); +} + +/** A small rounded label in one of the status tones. */ +export function Pill({ label, tone }: { label: string; tone: 'error' | 'warning' | 'success' | 'info' | 'muted' }) { + const { c, styles } = useCardStyles(); + const colors = { + error: { bg: c.errorBg, fg: c.error }, + warning: { bg: c.warningBg, fg: c.warning }, + success: { bg: c.successBg, fg: c.success }, + info: { bg: c.primaryBg, fg: c.info }, + muted: { bg: c.surfaceHover, fg: c.textSecondary }, + }[tone]; + return ( + + {label} + + ); +} + +/** Cancelled, Pending or On hold; nothing for a confirmed booking. */ +export function BookingStatusPill({ status }: { status?: ReservationStatus }) { + const t = useLocaleStore((s) => s.t); + if (!status || status === 'confirmed') return null; + const label = status === 'cancelled' + ? t('email_viewer.structured_mail.status_cancelled', 'Cancelled') + : status === 'pending' + ? t('email_viewer.structured_mail.status_pending', 'Pending') + : t('email_viewer.structured_mail.status_hold', 'On hold'); + return ; +} + +/** + * A link from the mail with its host beside the label ("Track · dhl.de"). + * Opens through the app's URL guard, which only hands http(s) to the OS. + */ +export function CardLink({ link, label, icon: Icon = ExternalLink }: { link?: Link; label: string; icon?: AppIcon }) { + const { c, styles } = useCardStyles(); + if (!link || !isWebLink(link)) return null; + return ( + { void openExternalUrl(link.url); }} + style={({ pressed }) => [styles.action, pressed && styles.actionPressed]} + accessibilityRole="link" + accessibilityLabel={`${label}, ${link.host}`} + testID="structured-mail-link" + > + + {label} + {/* The host is how a reader judges the link: wrapped, never cut off. */} + · {link.host} + + ); +} + +interface CardShellProps { + /** Accessible name of the card ("Flight booking"). */ + name: string; + icon: AppIcon; + /** Destructive tone and a struck-through title. */ + cancelled?: boolean; + eyebrow: string; + title: string; + /** What screen readers hear instead of the title (a route written with arrows). */ + titleLabel?: string; + pills?: React.ReactNode; + children?: React.ReactNode; + actions?: React.ReactNode; + kind: string; +} + +/** The banner layout every card shares (CalendarInvitationBanner's look). */ +export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title, titleLabel, pills, children, actions, kind }: CardShellProps) { + const { c, styles } = useCardStyles(); + return ( + + + + + + + {eyebrow} + + {title} + + + {pills ? {pills} : null} + {children ? {children} : null} + {actions ? {actions} : null} + + + ); +} + +/** "Flight · Lufthansa"; the kind alone when the provider is missing or already the title. */ +export function eyebrowText(kind: string, provider: string | undefined, title: string): string { + const p = provider?.trim(); + return p && p.toLowerCase() !== title.trim().toLowerCase() ? `${kind} · ${p}` : kind; +} diff --git a/src/components/email/structured-mail/copy-value-chip.tsx b/src/components/email/structured-mail/copy-value-chip.tsx new file mode 100644 index 00000000..f80c5b2b --- /dev/null +++ b/src/components/email/structured-mail/copy-value-chip.tsx @@ -0,0 +1,86 @@ +// Port of webmail components/email/structured-mail/copy-value-chip.tsx at +// b0782af2d, on expo-clipboard like the app's VerificationCodeChip. +import React from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { Check, Copy } from '../../icons'; +import { useLocaleStore } from '../../../stores/locale-store'; +import { toast } from '../../../stores/toast-store'; +import { useColors } from '../../../theme/colors'; +import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens'; +import { ltr } from './card-parts'; + +interface CopyValueChipProps { + /** What a tap copies: a booking reference, order or tracking number. */ + value: string; + /** The button's accessible name, naming what is copied ("Copy booking reference QX7K2M"). */ + copyLabel: string; + /** The toast after copying ("Booking reference copied"). */ + copiedMessage: string; + /** A visible label in front of the value ("Booking reference"). */ + label?: string; + /** As tall as the buttons beside it, in an action row. */ + inActions?: boolean; +} + +/** A number from a booking or parcel card; a tap copies it. Looks like the verification code chip. */ +export function CopyValueChip({ value, copyLabel, copiedMessage, label, inActions }: CopyValueChipProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (!copied) return undefined; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + const copy = () => { + Clipboard.setStringAsync(value).then( + () => { + setCopied(true); + toast.success(copiedMessage); + }, + () => toast.error(t('email_viewer.structured_mail.copy_failed', 'Could not copy to the clipboard')), + ); + }; + + const Icon = copied ? Check : Copy; + return ( + [styles.chip, inActions && styles.chipInActions, pressed && styles.chipPressed]} + accessibilityRole="button" + accessibilityLabel={copyLabel} + testID="copy-value-chip" + > + + {label ? {label} : null} + {/* Digit groups keep their order in a right-to-left UI. */} + {ltr(value)} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + chip: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 1, + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.background, + }, + chipInActions: { minHeight: 36, paddingHorizontal: spacing.md, borderRadius: radius.md }, + chipPressed: { backgroundColor: c.surfaceHover }, + label: { ...typography.caption, color: c.textMuted }, + value: { ...typography.caption, color: c.text, fontWeight: '600', fontFamily: 'monospace', letterSpacing: 0.5, flexShrink: 1 }, + }); +} diff --git a/src/components/email/structured-mail/icons.ts b/src/components/email/structured-mail/icons.ts new file mode 100644 index 00000000..0662dcd1 --- /dev/null +++ b/src/components/email/structured-mail/icons.ts @@ -0,0 +1,19 @@ +// The card glyphs the app's generated icon set (src/components/icons, from +// scripts/generate-icons.mjs) doesn't ship yet, from the same Tabler outline +// data (MIT) under the webmail's names. Once they are added to MAP in that +// script, import them from components/icons and delete this file. + +import { makeIcon } from '../../icons/icon'; + +export const Bed = makeIcon('Bed', ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M22 17v-3h-20", "M2 8v9", "M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"]); +export const Bus = makeIcon('Bus', ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8", "M16 5l1.5 7l4.5 0", "M2 10l15 0", "M7 5l0 5", "M12 5l0 5"]); +export const CalendarCheck = makeIcon('CalendarCheck', ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6", "M16 3v4", "M8 3v4", "M4 11h16", "M15 19l2 2l4 -4"]); +export const CalendarX = makeIcon('CalendarX', ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5", "M16 3v4", "M8 3v4", "M4 11h16", "M22 22l-5 -5", "M17 22l5 -5"]); +export const Car = makeIcon('Car', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"]); +export const Package = makeIcon('Package', ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5", "M12 12l8 -4.5", "M12 12l0 9", "M12 12l-8 -4.5", "M16 5.25l-8 4.5"]); +export const Plane = makeIcon('Plane', ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"]); +export const Restaurant = makeIcon('Restaurant', ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"]); +export const ShoppingBag = makeIcon('ShoppingBag', ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304", "M9 11v-5a3 3 0 0 1 6 0v5"]); +export const Ticket = makeIcon('Ticket', ["M15 5l0 2", "M15 11l0 2", "M15 17l0 2", "M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"]); +export const Train = makeIcon('Train', ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8", "M3 15h16a2 2 0 0 0 2 -2", "M3 6v5h17.5", "M3 11v4", "M8 11v-5", "M13 11v-4.5", "M3 19h18"]); +export const Truck = makeIcon('Truck', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"]); diff --git a/src/components/email/structured-mail/shipment-card.tsx b/src/components/email/structured-mail/shipment-card.tsx new file mode 100644 index 00000000..0a8671a9 --- /dev/null +++ b/src/components/email/structured-mail/shipment-card.tsx @@ -0,0 +1,171 @@ +// Port of webmail components/email/structured-mail/shipment-card.tsx at +// b0782af2d (same rows, React Native views). +import React from 'react'; +import { Image, Text, View } from 'react-native'; +import { Clock, ExternalLink } from '../../icons'; +import { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from '../../../lib/structured-mail/format'; +import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types'; +import { useLocaleStore } from '../../../stores/locale-store'; +import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts'; +import { CopyValueChip } from './copy-value-chip'; +import { Package, ShoppingBag, Truck } from './icons'; + +const STATUS_TONE: Record = { + payment_due: 'warning', + processing: 'muted', + problem: 'error', + in_transit: 'info', + pickup_available: 'success', + returned: 'muted', + delivered: 'success', + cancelled: 'error', +}; + +/** States in which an expected arrival date is out of date. */ +const ARRIVED_OR_STOPPED = new Set(['delivered', 'returned', 'cancelled']); + +function OrderStatusPill({ status }: { status: OrderStatus }) { + const t = useLocaleStore((s) => s.t); + const labels: Record = { + payment_due: t('email_viewer.structured_mail.order_status_payment_due', 'Payment due'), + processing: t('email_viewer.structured_mail.order_status_processing', 'Processing'), + problem: t('email_viewer.structured_mail.order_status_problem', 'Problem'), + in_transit: t('email_viewer.structured_mail.order_status_in_transit', 'Shipped'), + pickup_available: t('email_viewer.structured_mail.order_status_pickup_available', 'Ready for pickup'), + returned: t('email_viewer.structured_mail.order_status_returned', 'Returned'), + delivered: t('email_viewer.structured_mail.order_status_delivered', 'Delivered'), + cancelled: t('email_viewer.structured_mail.order_status_cancelled', 'Cancelled'), + }; + return ; +} + +/** Product images, only when remote content may load for this message; https only. */ +function ItemImages({ items }: { items: Item[] }) { + const { styles } = useCardStyles(); + const shown = items.filter((item) => item.image && isWebLink(item.image) && item.image.url.toLowerCase().startsWith('https:')); + if (shown.length === 0) return null; + return ( + + {shown.map((item, i) => ( + + ))} + + ); +} + +/** Carrier, tracking number and expected date; no date once the order is delivered, returned or cancelled. */ +function ParcelRows({ parcel, showExpected }: { parcel: Parcel; showExpected: boolean }) { + const t = useLocaleStore((s) => s.t); + const { c, styles } = useCardStyles(); + const { opts } = useCardFormat(); + const expectedBy = showExpected ? parcel.expectedBy : undefined; + if (!parcel.carrier && !parcel.trackingNumber && !expectedBy) return null; + return ( + <> + {(parcel.carrier || parcel.trackingNumber) ? ( + + + + {parcel.carrier ? {parcel.carrier} : null} + {!parcel.carrier && parcel.trackingNumber ? ( + {t('email_viewer.structured_mail.tracking_number', 'Tracking number')} + ) : null} + {parcel.trackingNumber ? ( + + ) : null} + + + ) : null} + {expectedBy ? ( + {t('email_viewer.structured_mail.expected_by', 'Arrives by {date}', { date: formatMomentDate(expectedBy, opts) })} + ) : null} + + ); +} + +interface ShipmentCardProps { + shipment: Shipment; + allowRemoteImages: boolean; +} + +export function ShipmentCard({ shipment, allowRemoteImages }: ShipmentCardProps) { + const t = useLocaleStore((s) => s.t); + const { c, styles } = useCardStyles(); + const { opts } = useCardFormat(); + const carrier = shipment.parcels.find((p) => p.carrier)?.carrier; + const isParcel = !shipment.orderNumber && !shipment.merchant && shipment.parcels.length > 0; + const kindLabel = isParcel + ? t('email_viewer.structured_mail.kind_parcel', 'Parcel') + : t('email_viewer.structured_mail.kind_order', 'Order'); + const title = shipment.merchant?.trim() || carrier || kindLabel; + const eyebrow = shipment.merchant && carrier ? `${kindLabel} · ${carrier}` : kindLabel; + const items = shipmentItems(shipment); + const { shown, more, count } = summarizeItems(items); + const total = shipment.total ? formatMoney(shipment.total, opts.locale) : undefined; + // One Track button per distinct tracking page. + const trackLinks = shipment.parcels.map((p) => p.trackingUrl) + .filter((l, i, all): l is Link => isWebLink(l) && all.findIndex((o) => o?.url === l!.url) === i); + const hasOrderLink = isWebLink(shipment.orderUrl); + const cancelled = shipment.status === 'cancelled'; + const summary = [ + items.length > 0 ? t('email_viewer.structured_mail.items_count', '{count, plural, one {# item} other {# items}}', { count }) : undefined, + total ? t('email_viewer.structured_mail.total', 'Total {total}', { total }) : undefined, + ].filter(Boolean).join(' · '); + + return ( + : undefined} + actions={trackLinks.length > 0 || hasOrderLink ? ( + <> + {trackLinks.map((link, i) => ( + + ))} + + + ) : undefined} + > + {shipment.orderNumber ? ( + + + + {t('email_viewer.structured_mail.order_number', 'Order number')} + + + + ) : null} + {shown.length > 0 ? ( + + + {shown.map((item) => item.name).join(', ')} + {more > 0 ? ` ${t('email_viewer.structured_mail.items_more', 'and {count} more', { count: more })}` : null} + + {allowRemoteImages ? : null} + + ) : null} + {summary ? {summary} : null} + {shipment.parcels.map((parcel, i) => ( + + ))} + + ); +} diff --git a/src/components/email/structured-mail/sources.ts b/src/components/email/structured-mail/sources.ts new file mode 100644 index 00000000..ff45465d --- /dev/null +++ b/src/components/email/structured-mail/sources.ts @@ -0,0 +1,26 @@ +// Mirrors webmail components/email/structured-mail/sources.ts at 66c73d0f4; +// keep the two in step. App: the blob context is the JMAP account id alone, +// since the app has one client (api/jmap-client). +import type { Email } from '../../../api/types'; +import type { StructuredCard } from '../../../lib/structured-mail'; + +/** + * Card sources beyond the HTML markup, read asynchronously once a trusted + * message is open (Apple Wallet passes among its attachments). Each gets the + * message and returns cards that are validated but not zone-resolved; the + * hook merges them with the markup cards and resolves zones for all. A + * source must read only `email.attachments` (never parts unwrapped from an + * attached message) and must not throw. + */ +export interface CardSourceContext { + email: Email; + mailDate: Date; + /** JMAP account the message's blobs live in; undefined for the primary account. */ + accountId: string | undefined; + signal: AbortSignal; +} + +export type CardSource = (context: CardSourceContext) => Promise; + +/** Registered sources. */ +export const CARD_SOURCES: CardSource[] = []; diff --git a/src/components/email/structured-mail/structured-mail-cards.tsx b/src/components/email/structured-mail/structured-mail-cards.tsx new file mode 100644 index 00000000..8487ebd0 --- /dev/null +++ b/src/components/email/structured-mail/structured-mail-cards.tsx @@ -0,0 +1,49 @@ +// Port of webmail components/email/structured-mail/structured-mail-cards.tsx +// at 972e5f621. App: links need no message id (the app has no plugin link +// hook), so the cards don't take the email. +import React from 'react'; +import { View } from 'react-native'; +import type { Booking, StructuredCard } from '../../../lib/structured-mail/types'; +import { EventCard, LodgingCard, RentalCarCard, RestaurantCard, TransportCard } from './booking-cards'; +import { useCardStyles } from './card-parts'; +import { ShipmentCard } from './shipment-card'; + +export interface StructuredMailCardsProps { + /** Resolved cards from useStructuredMail; nothing renders for an empty list. */ + cards: StructuredCard[]; + /** Whether remote content may load for this message: product images show only then. */ + allowRemoteImages: boolean; + /** Rendered in each booking card's action row ("Add to calendar"); nothing when undefined. */ + calendarActions?: (booking: Booking) => React.ReactNode; +} + +/** The booking, parcel and order cards shown above an open message. */ +export function StructuredMailCards({ cards, allowRemoteImages, calendarActions }: StructuredMailCardsProps) { + const { styles } = useCardStyles(); + if (cards.length === 0) return null; + return ( + + {cards.map((card, i) => { + const key = `${card.key}-${i}`; + switch (card.kind) { + case 'flight': + case 'train': + case 'bus': + return ; + case 'lodging': + return ; + case 'rentalcar': + return ; + case 'event': + return ; + case 'restaurant': + return ; + case 'shipment': + return ; + default: + return null; + } + })} + + ); +} diff --git a/src/components/email/structured-mail/use-structured-mail.ts b/src/components/email/structured-mail/use-structured-mail.ts new file mode 100644 index 00000000..ca5276ce --- /dev/null +++ b/src/components/email/structured-mail/use-structured-mail.ts @@ -0,0 +1,83 @@ +// Mirrors webmail components/email/structured-mail/use-structured-mail.ts at +// ba6d5e448; keep the two in step. App: the blob account is the reader's +// `jmapAccountId` (a shared mailbox's owner), else the message's own stamp. +import { useEffect, useMemo, useState } from 'react'; +import type { Email } from '../../../api/types'; +import { + loadAirportZones, + mergeCandidates, + needsAirportZones, + readStructuredMailDetailed, + resolveCards, + type StructuredCard, +} from '../../../lib/structured-mail'; +import { useStructuredMailEnabled } from '../../../lib/structured-mail/gate'; +import { getStructuredMailTrust, structuredHtmlPart } from '../../../lib/structured-mail/trust'; +import { CARD_SOURCES } from './sources'; + +function mailDateOf(email: Pick): Date { + return new Date(email.sentAt ?? email.receivedAt); +} + +async function resolveAll(cards: StructuredCard[]): Promise { + if (!needsAirportZones(cards)) return resolveCards(cards); + try { + return resolveCards(cards, await loadAirportZones()); + } catch { + // Without the table no flight card can be checked, so none is shown. + return resolveCards(cards); + } +} + +/** + * The booking, parcel and order cards of an open message: empty unless the + * gate is on and the message passes getStructuredMailTrust. Cards that + * need nothing asynchronous show at once; flights wait for the airport time + * zone table, and cards from attachment sources (CARD_SOURCES) are merged in + * once read, so a card never shows with unchecked times. A message whose + * markup describes a booking (even one validation rejected) is read from the + * markup alone: the sources aren't asked, so nothing is downloaded and a pass + * can't revive a rejected or cancelled booking or show one booking twice. + */ +export function useStructuredMail(email: Email | null | undefined, jmapAccountId?: string): StructuredCard[] { + const enabled = useStructuredMailEnabled(); + const trusted = useMemo(() => !!email && enabled && getStructuredMailTrust(email).trusted, [email, enabled]); + const html = email ? structuredHtmlPart(email)?.value : undefined; + // An HTML part whose text hasn't loaded yet may still hold booking markup. + const htmlPending = html === undefined && !!email?.htmlBody?.some((part) => part.type === 'text/html'); + + const { cards: markup, hadBookingMarkup } = useMemo(() => { + if (!trusted || !email || !html) return { cards: [] as StructuredCard[], hadBookingMarkup: false }; + return readStructuredMailDetailed(html, { mailDate: mailDateOf(email) }); + // email.id and the HTML identify the input; the rest of the object may change identity freely. + }, [trusted, email?.id, html]); // eslint-disable-line react-hooks/exhaustive-deps + const askSources = !hadBookingMarkup && !htmlPending; + + const immediate = useMemo(() => resolveCards(markup.filter((card) => card.kind !== 'flight')), [markup]); + const [settled, setSettled] = useState<{ key: string; cards: StructuredCard[] } | null>(null); + const accountId = jmapAccountId ?? email?.jmapAccountId; + const settleKey = `${email?.id ?? ''}|${accountId ?? ''}|${trusted}|${markup.length}|${askSources}`; + + useEffect(() => { + if (!trusted || !email) return; + const sources = askSources ? CARD_SOURCES : []; + const needsWork = needsAirportZones(markup) || sources.length > 0; + if (!needsWork) return; + const controller = new AbortController(); + (async () => { + const fromSources = (await Promise.all(sources.map((source) => + source({ email, mailDate: mailDateOf(email), accountId, signal: controller.signal }).catch(() => [] as StructuredCard[]), + ))).flat(); + const combined = fromSources.length > 0 + ? mergeCandidates([...markup, ...fromSources].map((card) => ({ card, problems: [] }))) + : markup; + const cards = await resolveAll(combined); + if (!controller.signal.aborted) setSettled({ key: settleKey, cards }); + })(); + return () => controller.abort(); + // markup, askSources and settleKey change together with the message. + }, [trusted, markup, settleKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!trusted) return []; + return settled?.key === settleKey ? settled.cards : immediate; +}