mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: show a card with times and tracking above booking, shipping and order mail
This commit is contained in:
@@ -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 <StructuredMailCards cards={cards} allowRemoteImages={allowRemoteImages} />;
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
children: Array<HostNode | string>;
|
||||
}
|
||||
|
||||
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<HostNode | string>): 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<string, any> };
|
||||
const { type, props } = element;
|
||||
if (type === FRAGMENT) {
|
||||
renderNode(props.children, out);
|
||||
return;
|
||||
}
|
||||
if (typeof type === 'string') {
|
||||
const children: Array<HostNode | string> = [];
|
||||
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<HostNode | string> = [];
|
||||
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<string, any> {
|
||||
const flatten = (style: unknown): Record<string, any> => {
|
||||
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<string, any>;
|
||||
};
|
||||
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<T> {
|
||||
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<T>(hook: () => T): HookResult<T> {
|
||||
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<void> {
|
||||
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: <T,>(styles: T) => styles, hairlineWidth: 1, flatten: (s: unknown) => s },
|
||||
Platform: { OS: 'android', Version: 33, select: <T,>(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() {} },
|
||||
};
|
||||
}
|
||||
@@ -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: `<script type="application/ld+json">${JSON.stringify(ORDER)}</script><p>Thanks</p>` } },
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, StructuredCard[]>;
|
||||
const card = <T extends StructuredCard = StructuredCard>(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<StructuredMailCardsProps> = {}): 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<TransportBooking>('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<TransportBooking>('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<TransportBooking>('train-multiple-types');
|
||||
pending.status = 'pending';
|
||||
const hold = card<Booking>('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<Shipment>('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<TransportBooking>('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<Booking>('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<Shipment>('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);
|
||||
});
|
||||
});
|
||||
@@ -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) => `<script type="application/ld+json">${JSON.stringify(value)}</script>`;
|
||||
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: `<html><head>${body}</head><body></body></html>`, 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']);
|
||||
});
|
||||
});
|
||||
@@ -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<B extends Booking = Booking> {
|
||||
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 (
|
||||
<BookingActions booking={booking} calendarActions={calendarActions}>
|
||||
{shown.map((l) => <CardLink key={l.label} link={l.link} label={l.label} />)}
|
||||
</BookingActions>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingActions({ booking, calendarActions, children }: Pick<BookingCardProps, 'booking' | 'calendarActions'> & { children: React.ReactNode }) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<>
|
||||
{booking.reference ? (
|
||||
<CopyValueChip
|
||||
value={booking.reference}
|
||||
label={t('email_viewer.structured_mail.reference', 'Booking reference')}
|
||||
copyLabel={t('email_viewer.structured_mail.copy_reference', 'Copy booking reference {value}', { value: booking.reference })}
|
||||
copiedMessage={t('email_viewer.structured_mail.copied_reference', 'Booking reference copied')}
|
||||
inActions
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
{calendarActions?.(booking)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PassengersRow({ names }: { names: string[] }) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
if (names.length === 0) return null;
|
||||
return (
|
||||
<View accessibilityLabel={`${t('email_viewer.structured_mail.passengers', 'Passengers')}: ${names.join(', ')}`}>
|
||||
<MetaRow icon={Users}>{names.map(isolate).join(', ')}</MetaRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.leg} testID={cancelled ? 'structured-mail-leg-cancelled' : 'structured-mail-leg'}>
|
||||
{(service || (cancelled && !bookingCancelled)) ? (
|
||||
<View style={styles.legHead}>
|
||||
{service ? (
|
||||
<View style={styles.legService}>
|
||||
<Icon size={14} color={cancelled ? c.textMuted : c.text} />
|
||||
<Text style={[styles.legServiceText, cancelled && styles.titleCancelled]}>{service}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{cancelled && !bookingCancelled ? (
|
||||
<Pill label={t('email_viewer.structured_mail.status_cancelled', 'Cancelled')} tone="error" />
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<MetaRow icon={Clock}>
|
||||
<When at={leg.departure.at} struck={cancelled} />
|
||||
<Text style={cancelled ? styles.struck : undefined}> · {isolate(legPlaceLabel(leg.departure.place, kind))}</Text>
|
||||
</MetaRow>
|
||||
{arrival ? (
|
||||
<MetaRow icon={ArrowRight}>
|
||||
{arrival.at ? <When at={arrival.at} timeOnly={sameLocalDate(arrival.at, leg.departure.at)} struck={cancelled} /> : null}
|
||||
<Text style={cancelled ? styles.struck : undefined}>{arrival.at ? ' · ' : ''}{isolate(legPlaceLabel(arrival.place, kind))}</Text>
|
||||
</MetaRow>
|
||||
) : null}
|
||||
{leg.seats.length > 0 ? (
|
||||
<MetaRow icon={Ticket} testID="structured-mail-seats">
|
||||
{t('email_viewer.structured_mail.seats', 'Seats')}:{' '}
|
||||
{leg.seats.map((seat) => `${seat.passenger ? `${isolate(seat.passenger)} ` : ''}${ltr(seat.seat)}`).join(', ')}
|
||||
</MetaRow>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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<TransportBooking>) {
|
||||
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 (
|
||||
<CardShell
|
||||
kind={booking.kind}
|
||||
name={name}
|
||||
icon={TRANSPORT_ICON[booking.kind]}
|
||||
cancelled={cancelled}
|
||||
eyebrow={eyebrowText(kindLabel, booking.provider, routeTitle(booking))}
|
||||
title={title}
|
||||
titleLabel={runs.map((run) => run.reduce((from, to) => t('email_viewer.structured_mail.route', '{from} to {to}', { from, to }))).join(', ')}
|
||||
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : 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') },
|
||||
])}
|
||||
>
|
||||
<View style={styles.legs}>
|
||||
{booking.legs.map((leg, i) => (
|
||||
<Leg key={`${leg.departure.at.date}-${leg.number ?? ''}-${i}`} leg={leg} kind={booking.kind} bookingCancelled={cancelled} />
|
||||
))}
|
||||
</View>
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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 (
|
||||
<MetaRow icon={Clock}>
|
||||
{label}{' '}
|
||||
<Text style={styles.strong}>{formatMomentDate(at, opts)}</Text>
|
||||
{time ? (
|
||||
<>
|
||||
{', '}
|
||||
<Text style={styles.strong}>
|
||||
{edge === 'from'
|
||||
? t('email_viewer.structured_mail.from_time', 'from {time}', { time })
|
||||
: t('email_viewer.structured_mail.until_time', 'until {time}', { time })}
|
||||
</Text>
|
||||
{isOtherZone(at, readerZone) ? <LocalTimeHint /> : null}
|
||||
</>
|
||||
) : null}
|
||||
</MetaRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function LodgingCard({ booking, calendarActions }: BookingCardProps<LodgingBooking>) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const { opts } = useCardFormat();
|
||||
const nights = nightsBetween(booking.checkin, booking.checkout);
|
||||
const address = formatAddress(booking.address, opts.locale);
|
||||
return (
|
||||
<CardShell
|
||||
kind="lodging"
|
||||
name={t('email_viewer.structured_mail.region_lodging', 'Hotel booking')}
|
||||
icon={Bed}
|
||||
cancelled={booking.status === 'cancelled'}
|
||||
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_lodging', 'Hotel'), booking.provider, booking.name)}
|
||||
title={booking.name}
|
||||
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : 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 ? <MetaRow icon={MapPin}>{address}</MetaRow> : null}
|
||||
<StayEnd label={t('email_viewer.structured_mail.check_in_label', 'Check-in')} at={booking.checkin} edge="from" />
|
||||
<StayEnd label={t('email_viewer.structured_mail.check_out_label', 'Check-out')} at={booking.checkout} edge="until" />
|
||||
{nights !== undefined ? (
|
||||
<MetaRow icon={Bed}>{t('email_viewer.structured_mail.nights', '{count, plural, one {# night} other {# nights}}', { count: nights })}</MetaRow>
|
||||
) : null}
|
||||
{booking.phone ? <MetaRow icon={Phone}>{ltr(booking.phone)}</MetaRow> : null}
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RentalCarCard({ booking, calendarActions }: BookingCardProps<RentalCarBooking>) {
|
||||
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 (
|
||||
<CardShell
|
||||
kind="rentalcar"
|
||||
name={t('email_viewer.structured_mail.region_rentalcar', 'Rental car booking')}
|
||||
icon={Car}
|
||||
cancelled={booking.status === 'cancelled'}
|
||||
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_rentalcar', 'Rental car'), booking.provider, title)}
|
||||
title={title}
|
||||
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, [
|
||||
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||
])}
|
||||
>
|
||||
<MetaRow icon={Clock}>
|
||||
{t('email_viewer.structured_mail.pickup', 'Pick-up')}{' '}
|
||||
<When at={booking.pickup.at} />
|
||||
{pickupPlace ? ` · ${pickupPlace}` : null}
|
||||
</MetaRow>
|
||||
<MetaRow icon={ArrowRight}>
|
||||
{t('email_viewer.structured_mail.dropoff', 'Return')}{' '}
|
||||
<When at={booking.dropoff.at} />
|
||||
{dropoffPlace ? ` · ${dropoffPlace}` : null}
|
||||
</MetaRow>
|
||||
{booking.car ? <MetaRow icon={Car}>{booking.car}</MetaRow> : null}
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventCard({ booking, calendarActions }: BookingCardProps<EventBooking>) {
|
||||
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 (
|
||||
<CardShell
|
||||
kind="event"
|
||||
name={t('email_viewer.structured_mail.region_event', 'Event booking')}
|
||||
icon={Ticket}
|
||||
cancelled={booking.status === 'cancelled'}
|
||||
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_event', 'Event'), booking.provider, booking.name)}
|
||||
title={booking.name}
|
||||
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : 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') },
|
||||
])}
|
||||
>
|
||||
<MetaRow icon={Clock}>
|
||||
<When at={booking.start} />
|
||||
{end && (end.time || !sameLocalDate(end, booking.start)) ? (
|
||||
<>
|
||||
{' – '}
|
||||
<When at={end} timeOnly={sameLocalDate(end, booking.start) && !!booking.start.time} />
|
||||
</>
|
||||
) : null}
|
||||
</MetaRow>
|
||||
{venue ? <MetaRow icon={MapPin}>{venue}</MetaRow> : null}
|
||||
{booking.tickets.length > 0 ? (
|
||||
<MetaRow icon={Ticket} testID="structured-mail-tickets">
|
||||
{t('email_viewer.structured_mail.tickets', 'Tickets')}:{' '}
|
||||
<Text style={styles.mono}>{ltr(booking.tickets.join(', '))}</Text>
|
||||
</MetaRow>
|
||||
) : null}
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestaurantCard({ booking, calendarActions }: BookingCardProps<RestaurantBooking>) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const { opts } = useCardFormat();
|
||||
const address = formatAddress(booking.address, opts.locale);
|
||||
return (
|
||||
<CardShell
|
||||
kind="restaurant"
|
||||
name={t('email_viewer.structured_mail.region_restaurant', 'Restaurant reservation')}
|
||||
icon={Restaurant}
|
||||
cancelled={booking.status === 'cancelled'}
|
||||
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_restaurant', 'Restaurant'), booking.provider, booking.name)}
|
||||
title={booking.name}
|
||||
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, [
|
||||
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||
])}
|
||||
>
|
||||
<MetaRow icon={Clock}><When at={booking.start} /></MetaRow>
|
||||
{address ? <MetaRow icon={MapPin}>{address}</MetaRow> : null}
|
||||
{booking.partySize !== undefined && booking.partySize > 0 ? (
|
||||
<MetaRow icon={Users}>
|
||||
{t('email_viewer.structured_mail.party_size', '{count, plural, one {Table for #} other {Table for #}}', { count: booking.partySize })}
|
||||
</MetaRow>
|
||||
) : null}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -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 <bdi> 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 <bdi> and <bdi dir="ltr"> 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 <bdi>). */
|
||||
export function isolate(text: string): string {
|
||||
return `${FSI}${text}${PDI}`;
|
||||
}
|
||||
|
||||
/** Digits and codes that read left to right in a right-to-left UI (<bdi dir="ltr">). */
|
||||
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<typeof makeCardStyles>;
|
||||
|
||||
/** 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 style={[styles.strong, struck && styles.struck]}>{text}</Text>
|
||||
{other && <LocalTimeHint />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "local time" mark after a time written at the place. */
|
||||
export function LocalTimeHint() {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const { styles } = useCardStyles();
|
||||
return (
|
||||
<Text
|
||||
style={styles.hint}
|
||||
testID="local-time-hint"
|
||||
accessibilityHint={t('email_viewer.structured_mail.local_time_title', 'Local time at the place, not your time zone')}
|
||||
>
|
||||
{' '}{t('email_viewer.structured_mail.local_time', 'local time')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<View style={styles.metaRow} testID={testID}>
|
||||
<Icon size={14} color={c.textSecondary} style={styles.metaIcon} />
|
||||
<Text style={styles.metaText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<View style={[styles.pill, { backgroundColor: colors.bg }]} testID="structured-mail-pill">
|
||||
<Text style={[styles.pillText, { color: colors.fg }]} numberOfLines={1}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <Pill label={label} tone={status === 'cancelled' ? 'error' : 'warning'} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Pressable
|
||||
onPress={() => { void openExternalUrl(link.url); }}
|
||||
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${label}, ${link.host}`}
|
||||
testID="structured-mail-link"
|
||||
>
|
||||
<Icon size={14} color={c.textSecondary} />
|
||||
<Text style={styles.actionText} numberOfLines={1}>{label}</Text>
|
||||
{/* The host is how a reader judges the link: wrapped, never cut off. */}
|
||||
<Text style={styles.actionHost}>· {link.host}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.card} accessibilityLabel={name} testID={`structured-mail-card-${kind}`}>
|
||||
<View style={[styles.badge, cancelled && styles.badgeCancelled]} importantForAccessibility="no-hide-descendants">
|
||||
<Icon size={20} color={cancelled ? c.error : c.primary} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>{eyebrow}</Text>
|
||||
<Text
|
||||
style={[styles.title, cancelled && styles.titleCancelled]}
|
||||
accessibilityRole="header"
|
||||
accessibilityLabel={titleLabel}
|
||||
testID="structured-mail-title"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{pills ? <View style={styles.pills}>{pills}</View> : null}
|
||||
{children ? <View style={styles.details}>{children}</View> : null}
|
||||
{actions ? <View style={styles.actions} testID="structured-mail-actions">{actions}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={copy}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => [styles.chip, inActions && styles.chipInActions, pressed && styles.chipPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={copyLabel}
|
||||
testID="copy-value-chip"
|
||||
>
|
||||
<Icon size={12} color={copied ? c.success : c.textMuted} />
|
||||
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||
{/* Digit groups keep their order in a right-to-left UI. */}
|
||||
<Text style={styles.value} selectable>{ltr(value)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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"]);
|
||||
@@ -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<OrderStatus, 'error' | 'warning' | 'success' | 'info' | 'muted'> = {
|
||||
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<OrderStatus>(['delivered', 'returned', 'cancelled']);
|
||||
|
||||
function OrderStatusPill({ status }: { status: OrderStatus }) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const labels: Record<OrderStatus, string> = {
|
||||
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 <Pill label={labels[status]} tone={STATUS_TONE[status]} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<View style={styles.images}>
|
||||
{shown.map((item, i) => (
|
||||
<Image
|
||||
key={`${item.image!.url}-${i}`}
|
||||
source={{ uri: item.image!.url }}
|
||||
accessibilityIgnoresInvertColors
|
||||
style={styles.image}
|
||||
testID="structured-mail-item-image"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) ? (
|
||||
<View style={[styles.metaRow, { alignItems: 'center' }]} testID="structured-mail-parcel">
|
||||
<Truck size={14} color={c.textSecondary} />
|
||||
<View style={[styles.pills, { flex: 1 }]}>
|
||||
{parcel.carrier ? <Text style={[styles.metaText, styles.strong, { flex: 0 }]}>{parcel.carrier}</Text> : null}
|
||||
{!parcel.carrier && parcel.trackingNumber ? (
|
||||
<Text style={[styles.metaText, { flex: 0 }]}>{t('email_viewer.structured_mail.tracking_number', 'Tracking number')}</Text>
|
||||
) : null}
|
||||
{parcel.trackingNumber ? (
|
||||
<CopyValueChip
|
||||
value={parcel.trackingNumber}
|
||||
copyLabel={t('email_viewer.structured_mail.copy_tracking_number', 'Copy tracking number {value}', { value: parcel.trackingNumber })}
|
||||
copiedMessage={t('email_viewer.structured_mail.copied_tracking_number', 'Tracking number copied')}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
{expectedBy ? (
|
||||
<MetaRow icon={Clock}>{t('email_viewer.structured_mail.expected_by', 'Arrives by {date}', { date: formatMomentDate(expectedBy, opts) })}</MetaRow>
|
||||
) : 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 (
|
||||
<CardShell
|
||||
kind="shipment"
|
||||
name={t('email_viewer.structured_mail.region_shipment', 'Order and delivery')}
|
||||
icon={isParcel ? Truck : ShoppingBag}
|
||||
cancelled={cancelled}
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
pills={shipment.status ? <OrderStatusPill status={shipment.status} /> : undefined}
|
||||
actions={trackLinks.length > 0 || hasOrderLink ? (
|
||||
<>
|
||||
{trackLinks.map((link, i) => (
|
||||
<CardLink key={`${link.url}-${i}`} link={link} label={t('email_viewer.structured_mail.link_track', 'Track')} icon={Truck} />
|
||||
))}
|
||||
<CardLink link={shipment.orderUrl} label={t('email_viewer.structured_mail.link_view_order', 'View order')} icon={ExternalLink} />
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{shipment.orderNumber ? (
|
||||
<View style={[styles.metaRow, { alignItems: 'center' }]} testID="structured-mail-order-number">
|
||||
<ShoppingBag size={14} color={c.textSecondary} />
|
||||
<View style={[styles.pills, { flex: 1 }]}>
|
||||
<Text style={[styles.metaText, { flex: 0 }]}>{t('email_viewer.structured_mail.order_number', 'Order number')}</Text>
|
||||
<CopyValueChip
|
||||
value={shipment.orderNumber}
|
||||
copyLabel={t('email_viewer.structured_mail.copy_order_number', 'Copy order number {value}', { value: shipment.orderNumber })}
|
||||
copiedMessage={t('email_viewer.structured_mail.copied_order_number', 'Order number copied')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
{shown.length > 0 ? (
|
||||
<View>
|
||||
<MetaRow icon={Package}>
|
||||
<Text style={styles.strong}>{shown.map((item) => item.name).join(', ')}</Text>
|
||||
{more > 0 ? ` ${t('email_viewer.structured_mail.items_more', 'and {count} more', { count: more })}` : null}
|
||||
</MetaRow>
|
||||
{allowRemoteImages ? <ItemImages items={shown} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{summary ? <Text style={styles.summary} testID="structured-mail-summary">{summary}</Text> : null}
|
||||
{shipment.parcels.map((parcel, i) => (
|
||||
<ParcelRows key={`${parcel.trackingNumber ?? ''}-${i}`} parcel={parcel} showExpected={!ARRIVED_OR_STOPPED.has(shipment.status ?? 'processing')} />
|
||||
))}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -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<StructuredCard[]>;
|
||||
|
||||
/** Registered sources. */
|
||||
export const CARD_SOURCES: CardSource[] = [];
|
||||
@@ -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 (
|
||||
<View style={styles.list} testID="structured-mail-cards">
|
||||
{cards.map((card, i) => {
|
||||
const key = `${card.key}-${i}`;
|
||||
switch (card.kind) {
|
||||
case 'flight':
|
||||
case 'train':
|
||||
case 'bus':
|
||||
return <TransportCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||
case 'lodging':
|
||||
return <LodgingCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||
case 'rentalcar':
|
||||
return <RentalCarCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||
case 'event':
|
||||
return <EventCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||
case 'restaurant':
|
||||
return <RestaurantCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||
case 'shipment':
|
||||
return <ShipmentCard key={key} shipment={card} allowRemoteImages={allowRemoteImages} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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<Email, 'sentAt' | 'receivedAt'>): Date {
|
||||
return new Date(email.sentAt ?? email.receivedAt);
|
||||
}
|
||||
|
||||
async function resolveAll(cards: StructuredCard[]): Promise<StructuredCard[]> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user