628 lines
21 KiB
TypeScript
628 lines
21 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import {
|
|
AlertTriangle,
|
|
BellOff,
|
|
Copy,
|
|
QrCode,
|
|
RefreshCw,
|
|
ScanLine,
|
|
ShieldCheck,
|
|
Smartphone,
|
|
Trash2,
|
|
Wifi,
|
|
WifiOff,
|
|
} from 'lucide-react-native';
|
|
import Clipboard from '@react-native-clipboard/clipboard';
|
|
import { useAlarm, useSubscriptions } from '../state/AlarmContext';
|
|
import { useConnection } from '../state/ConnectionContext';
|
|
import * as api from '../services/alarmApi';
|
|
import { formatAutoStop } from '../services/alarmApi';
|
|
import { relativeSince } from '../lib/time';
|
|
import { truncateToken } from '../lib/qrPayload';
|
|
import { TriggerPad } from '../components/TriggerPad';
|
|
import {
|
|
AppText,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
EmptyState,
|
|
ErrorBanner,
|
|
Field,
|
|
Header,
|
|
IconLine,
|
|
LoadingView,
|
|
OfflineStrip,
|
|
StatePill,
|
|
StateRing,
|
|
TextInputBox,
|
|
} from '../components/ui';
|
|
import type { RootStackParamList } from '../navigation';
|
|
import {
|
|
bg,
|
|
border,
|
|
danger,
|
|
focusRing,
|
|
minTapTarget,
|
|
radiusLg,
|
|
radiusMd,
|
|
space1,
|
|
space2,
|
|
space3,
|
|
space4,
|
|
space6,
|
|
stateArmed,
|
|
stateDisarmed,
|
|
surfaceRaised,
|
|
text,
|
|
textFaint,
|
|
textMuted,
|
|
textSecondary,
|
|
typeBodyStrong,
|
|
typeCaption,
|
|
typeCode,
|
|
typeHeading,
|
|
typeMicro,
|
|
warn,
|
|
type AlarmState,
|
|
} from '../theme/tokens';
|
|
|
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|
type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
|
|
|
|
const NEW = 'new';
|
|
|
|
export default function AlertDetailScreen() {
|
|
const navigation = useNavigation<Nav>();
|
|
const { params } = useRoute<Rt>();
|
|
const alertId = params?.alertId ?? NEW;
|
|
|
|
if (alertId === NEW) return <CreateAlertView onCreated={id => navigation.replace('AlertDetail', { alertId: id, role: 'admin' })} />;
|
|
|
|
return <AlertDetailView alertId={alertId} role={params?.role} />;
|
|
}
|
|
|
|
// ── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }) {
|
|
const navigation = useNavigation<Nav>();
|
|
const { createAlert } = useAlarm();
|
|
const [name, setName] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [autoStop, setAutoStop] = useState('60');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const submit = async () => {
|
|
if (!name.trim()) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const seconds = Number.parseInt(autoStop, 10);
|
|
const created = await createAlert({
|
|
name: name.trim(),
|
|
message: message.trim() || undefined,
|
|
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
|
|
});
|
|
onCreated(created.alertId);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Could not create the alert.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View style={styles.root}>
|
|
<Header title="New alert" subtitle="Create the alert, then show its code" onBack={() => navigation.goBack()} />
|
|
<ScrollView contentContainerStyle={styles.content}>
|
|
<Card>
|
|
<View style={{ gap: space3 }}>
|
|
<AppText style={{ ...typeHeading, color: text }}>What is this alert for?</AppText>
|
|
<Field label="Alert name">
|
|
<TextInputBox
|
|
value={name}
|
|
onChangeText={setName}
|
|
placeholder="Front door"
|
|
accessibilityLabel="Alert name"
|
|
/>
|
|
</Field>
|
|
<Field label="Message (optional)">
|
|
<TextInputBox
|
|
value={message}
|
|
onChangeText={setMessage}
|
|
placeholder="Come to the front door"
|
|
accessibilityLabel="Message"
|
|
/>
|
|
</Field>
|
|
<Field label="Auto-stop (seconds)">
|
|
<TextInputBox
|
|
value={autoStop}
|
|
onChangeText={setAutoStop}
|
|
keyboardType="number-pad"
|
|
placeholder="60"
|
|
accessibilityLabel="Auto-stop in seconds"
|
|
/>
|
|
</Field>
|
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
|
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
|
|
after this, on every device.
|
|
</AppText>
|
|
</View>
|
|
</Card>
|
|
|
|
{error ? <ErrorBanner message={error} onRetry={submit} /> : null}
|
|
|
|
<Button
|
|
label={busy ? 'Creating…' : 'Create alert'}
|
|
icon={ShieldCheck}
|
|
onPress={submit}
|
|
disabled={busy || !name.trim()}
|
|
/>
|
|
<AppText style={styles.note}>
|
|
The owner token is shown once, right after creation. Store it before you leave the next
|
|
screen — it cannot be recovered.
|
|
</AppText>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── Detail ───────────────────────────────────────────────────────────────────
|
|
|
|
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
|
|
const navigation = useNavigation<Nav>();
|
|
const { online } = useConnection();
|
|
const {
|
|
adminAlerts,
|
|
getAlert,
|
|
adminTokenFor,
|
|
updateAlertLocal,
|
|
forgetAdminAlert,
|
|
leaveAlert,
|
|
liveEvent,
|
|
refresh,
|
|
setActiveAlertId,
|
|
delivery,
|
|
socketState,
|
|
} = useAlarm();
|
|
|
|
const alert = getAlert(alertId);
|
|
const isAdmin = role === 'admin' || !!adminAlerts.find(a => a.alertId === alertId);
|
|
const adminToken = adminTokenFor(alertId);
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [revealToken, setRevealToken] = useState(false);
|
|
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
|
|
|
|
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
|
isAdmin ? alertId : null,
|
|
isAdmin,
|
|
);
|
|
|
|
// Read the authoritative state on mount; the local copy is only a cache.
|
|
const loadServerAlert = useCallback(async () => {
|
|
try {
|
|
const fresh = await api.getAlert(alertId);
|
|
setServerAlert(fresh);
|
|
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
|
} catch {
|
|
// Offline is fine — the cached copy renders.
|
|
}
|
|
}, [alertId, updateAlertLocal]);
|
|
|
|
useEffect(() => {
|
|
setActiveAlertId(isAdmin ? null : alertId);
|
|
loadServerAlert();
|
|
}, [alertId, isAdmin, loadServerAlert, setActiveAlertId]);
|
|
|
|
// `AlertStatus` IS the four-state ramp — no mapping needed.
|
|
const state: AlarmState = serverAlert?.status ?? alert?.status ?? 'idle';
|
|
// `armed`/`disarmed` are ringing-state history, not an enablement gate: there
|
|
// is no arm control and no `armAlert` action, and the service accepts a
|
|
// trigger from any state. So the copy labels the last event instead of
|
|
// promising a re-arm that does not exist.
|
|
const lastEventAt = serverAlert?.lastEvent?.serverTime ?? serverAlert?.triggeredAt ?? serverAlert?.disarmedAt;
|
|
const lastEventAgo = relativeSince(lastEventAt, Date.now());
|
|
const stateSentence =
|
|
state === 'triggered'
|
|
? 'Ringing now on every subscribed device.'
|
|
: state === 'disarmed'
|
|
? lastEventAgo
|
|
? `Disarmed ${lastEventAgo} — it will ring again the moment the owner triggers it.`
|
|
: 'Disarmed — it will ring again the moment the owner triggers it.'
|
|
: state === 'armed'
|
|
? lastEventAgo
|
|
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
|
|
: 'Armed — triggering rings every subscribed device.'
|
|
: 'Idle — triggering rings every subscribed device.';
|
|
const live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
|
|
const effectiveState = live ? 'triggered' : state;
|
|
const deviceCount = subs.length;
|
|
|
|
const doTrigger = async () => {
|
|
if (!adminToken) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await api.triggerAlert({ alertId, adminToken });
|
|
updateAlertLocal(alertId, { status: 'triggered', lastTriggered: res.serverTime });
|
|
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
|
|
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const doDisarm = async () => {
|
|
if (!adminToken) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.disarmAlert({ alertId, adminToken });
|
|
updateAlertLocal(alertId, { status: 'disarmed' });
|
|
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
|
|
setNotice('Disarmed on every device.');
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const doDelete = async () => {
|
|
if (!adminToken) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.deleteAlert({ alertId, adminToken });
|
|
await forgetAdminAlert(alertId);
|
|
navigation.goBack();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const doLeave = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await leaveAlert(alertId);
|
|
navigation.goBack();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Could not leave this alert.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const copy = async (value: string, label: string) => {
|
|
try {
|
|
Clipboard.setString(value);
|
|
setNotice(`${label} copied.`);
|
|
} catch {
|
|
setNotice('Could not copy — long-press the value to select it.');
|
|
}
|
|
};
|
|
|
|
if (!alert && !serverAlert) {
|
|
return (
|
|
<View style={styles.root}>
|
|
<Header title="Alert" onBack={() => navigation.goBack()} />
|
|
<EmptyState
|
|
icon={AlertTriangle}
|
|
title="Alert not found on this device"
|
|
subtitle="This device has no token for that alert. Scan its QR code again to rejoin."
|
|
action={<Button label="Scan a QR code" icon={ScanLine} onPress={() => navigation.navigate('Scanner')} />}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.root}>
|
|
{!online ? <OfflineStrip /> : null}
|
|
<Header
|
|
title={alert?.name ?? serverAlert?.name ?? alertId}
|
|
subtitle={isAdmin ? 'Owner controls' : 'Subscribed device'}
|
|
onBack={() => navigation.goBack()}
|
|
right={
|
|
<Pressable
|
|
onPress={refresh}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Refresh status"
|
|
style={styles.iconButton}
|
|
>
|
|
<RefreshCw size={18} color={textMuted} />
|
|
</Pressable>
|
|
}
|
|
/>
|
|
<ScrollView contentContainerStyle={styles.content}>
|
|
{error ? <ErrorBanner message={error} onRetry={loadServerAlert} /> : null}
|
|
{notice ? (
|
|
<View style={styles.notice} accessibilityLiveRegion="polite">
|
|
<AppText style={{ ...typeCaption, color: stateDisarmed }}>{notice}</AppText>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.ringWrap}>
|
|
<StateRing state={effectiveState} />
|
|
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
|
|
<AppText style={styles.stateLine}>{stateSentence}</AppText>
|
|
</View>
|
|
|
|
{isAdmin ? (
|
|
<AdminControls
|
|
adminToken={adminToken}
|
|
revealToken={revealToken}
|
|
onRevealToken={() => setRevealToken(true)}
|
|
onCopy={copy}
|
|
deviceCount={deviceCount}
|
|
busy={busy}
|
|
ringing={effectiveState === 'triggered'}
|
|
onTrigger={doTrigger}
|
|
onDisarm={doDisarm}
|
|
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
|
|
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
|
|
/>
|
|
) : (
|
|
<SubscriberControls
|
|
alertId={alertId}
|
|
alert={alert}
|
|
delivery={delivery}
|
|
socketState={socketState}
|
|
busy={busy}
|
|
onLeave={doLeave}
|
|
/>
|
|
)}
|
|
|
|
{isAdmin ? (
|
|
<View style={{ gap: space3 }}>
|
|
<View style={styles.sectionHead}>
|
|
<AppText style={{ ...typeHeading, color: text }}>Subscribed devices</AppText>
|
|
<AppText style={{ ...typeMicro, color: textFaint }}>{deviceCount}</AppText>
|
|
</View>
|
|
{subsError ? <ErrorBanner message={subsError} onRetry={reloadSubs} /> : null}
|
|
{subsLoading && !subs.length ? (
|
|
<LoadingView label="Loading devices…" />
|
|
) : subs.length === 0 ? (
|
|
<Card>
|
|
<IconLine icon={Smartphone} color={textMuted}>
|
|
No device has scanned this alert yet. Show the QR code to subscribe one.
|
|
</IconLine>
|
|
</Card>
|
|
) : (
|
|
subs.map(sub => (
|
|
<Card key={sub.subscriptionId}>
|
|
<View style={styles.subRow}>
|
|
<View style={{ flex: 1, gap: space1 }}>
|
|
<AppText style={{ ...typeBodyStrong, color: text }}>
|
|
{sub.deviceLabel || sub.deviceId}
|
|
</AppText>
|
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
|
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
|
|
</AppText>
|
|
</View>
|
|
<Pressable
|
|
onPress={async () => {
|
|
try {
|
|
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
|
|
reloadSubs();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
|
|
}
|
|
}}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
|
|
style={styles.iconButton}
|
|
>
|
|
<Trash2 size={18} color={danger} />
|
|
</Pressable>
|
|
</View>
|
|
</Card>
|
|
))
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
<Divider />
|
|
|
|
{/* Destructive ramp is separate from the alarm ramp (DESIGN.md §2.3). */}
|
|
{isAdmin ? (
|
|
<View style={{ gap: space2 }}>
|
|
<Button
|
|
label="Delete alert"
|
|
variant="danger"
|
|
icon={Trash2}
|
|
disabled={busy}
|
|
onPress={doDelete}
|
|
/>
|
|
<AppText style={styles.note}>
|
|
Deleting removes the alert and its tokens. Every subscribed device stops receiving it.
|
|
</AppText>
|
|
</View>
|
|
) : (
|
|
<View style={{ gap: space2 }}>
|
|
<Button label="Leave this alert" variant="danger" icon={BellOff} disabled={busy} onPress={doLeave} />
|
|
<AppText style={styles.note}>
|
|
Leaving removes this device from the alert. You can rejoin with the QR code.
|
|
</AppText>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AdminControls({
|
|
adminToken,
|
|
revealToken,
|
|
onRevealToken,
|
|
onCopy,
|
|
deviceCount,
|
|
busy,
|
|
ringing,
|
|
onTrigger,
|
|
onDisarm,
|
|
onShowQr,
|
|
autoStopSeconds,
|
|
}: {
|
|
adminToken: string | null;
|
|
revealToken: boolean;
|
|
onRevealToken: () => void;
|
|
onCopy: (value: string, label: string) => void;
|
|
deviceCount: number;
|
|
busy: boolean;
|
|
/** Does the alarm ring on other devices right now (drives the pad's copy). */
|
|
ringing: boolean;
|
|
onTrigger: () => void;
|
|
onDisarm: () => void;
|
|
onShowQr: () => void;
|
|
autoStopSeconds?: number;
|
|
}) {
|
|
return (
|
|
<View style={{ gap: space4 }}>
|
|
<Card>
|
|
<View style={{ gap: space3 }}>
|
|
<AppText style={{ ...typeHeading, color: text }}>Owner token</AppText>
|
|
{adminToken ? (
|
|
<>
|
|
<Pressable
|
|
onPress={() => (revealToken ? onCopy(adminToken, 'Owner token') : onRevealToken())}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={revealToken ? 'Copy owner token' : 'Reveal owner token'}
|
|
style={styles.tokenBox}
|
|
>
|
|
<AppText style={{ ...typeCode, color: text }}>
|
|
{revealToken ? truncateToken(adminToken, 24) : truncateToken(adminToken)}
|
|
</AppText>
|
|
<Copy size={16} color={textFaint} />
|
|
</Pressable>
|
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
|
{revealToken ? 'Tap to copy' : 'Tap to reveal the first 24 characters'}
|
|
</AppText>
|
|
</>
|
|
) : (
|
|
<IconLine icon={AlertTriangle} color={warn}>
|
|
No owner token is stored on this device, so this alert cannot be triggered from here.
|
|
</IconLine>
|
|
)}
|
|
</View>
|
|
</Card>
|
|
|
|
<Card>
|
|
<View style={{ gap: space3 }}>
|
|
<AppText style={{ ...typeHeading, color: text }}>Control</AppText>
|
|
<TriggerPad
|
|
ringing={ringing}
|
|
onTrigger={onTrigger}
|
|
onDisarm={onDisarm}
|
|
disabled={busy || !adminToken}
|
|
disabledReason={
|
|
!adminToken
|
|
? 'This device has no owner token, so it cannot trigger the alarm.'
|
|
: busy
|
|
? 'Working…'
|
|
: undefined
|
|
}
|
|
deviceCount={deviceCount}
|
|
/>
|
|
{autoStopSeconds ? (
|
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
|
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
|
|
</AppText>
|
|
) : null}
|
|
</View>
|
|
</Card>
|
|
|
|
<Card onPress={onShowQr} accessibilityLabel="Show the QR code to subscribe a device">
|
|
<View style={styles.qrRow}>
|
|
<QrCode size={22} color={focusRing} />
|
|
<View style={{ flex: 1 }}>
|
|
<AppText style={{ ...typeBodyStrong, color: text }}>Show QR code</AppText>
|
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
|
Subscribes a device in one scan
|
|
</AppText>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SubscriberControls({
|
|
alert,
|
|
delivery,
|
|
socketState,
|
|
busy,
|
|
onLeave,
|
|
}: {
|
|
alertId: string;
|
|
alert: api.Alert | undefined;
|
|
delivery: string;
|
|
socketState: string;
|
|
busy: boolean;
|
|
onLeave: () => void;
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<View style={{ gap: space3 }}>
|
|
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
|
|
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
|
|
{delivery === 'socket'
|
|
? `Connected for live delivery (${socketState}).`
|
|
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
|
|
</IconLine>
|
|
<IconLine icon={ShieldCheck} color={stateArmed}>
|
|
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
|
|
</IconLine>
|
|
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
|
|
</View>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: { flex: 1, backgroundColor: bg },
|
|
content: { padding: space4, paddingBottom: 48, gap: space4 },
|
|
iconButton: {
|
|
width: minTapTarget,
|
|
height: minTapTarget,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
notice: {
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(95,224,200,0.3)',
|
|
backgroundColor: 'rgba(95,224,200,0.1)',
|
|
borderRadius: radiusLg,
|
|
padding: space3,
|
|
},
|
|
ringWrap: { alignItems: 'center', paddingVertical: space6 },
|
|
stateLine: { ...typeCaption, color: textSecondary, textAlign: 'center', marginTop: space3 },
|
|
sectionHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' },
|
|
subRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
|
tokenBox: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: space3,
|
|
backgroundColor: surfaceRaised,
|
|
borderRadius: radiusMd,
|
|
borderWidth: 1,
|
|
borderColor: border,
|
|
paddingHorizontal: space4,
|
|
minHeight: minTapTarget,
|
|
},
|
|
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
|
note: { ...typeCaption, color: textSecondary },
|
|
});
|