init
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* QR payload contract for the Alarm client.
|
||||
*
|
||||
* Canonical payload (frozen contract):
|
||||
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
|
||||
*
|
||||
* Also accepted:
|
||||
* - the same URL with a trailing slash / query noise,
|
||||
* - a bare custom scheme: `dcntrlized-alarm://a/{alertId}#{token}`,
|
||||
* - a relative `a/{alertId}#{token}`,
|
||||
* - raw `{alertId}#{token}` (what the owner reads off the QR panel),
|
||||
* - a pasted token alone when the caller already knows the alertId
|
||||
* (manual fallback) — see `parseManualToken`.
|
||||
*
|
||||
* This module is intentionally pure (no React, no RN imports) so it is testable
|
||||
* under jest without a native environment, and so the same parser is used by
|
||||
* the scanner, the deep-link handler and the manual paste field.
|
||||
*/
|
||||
|
||||
export const QR_HOST = 'alarm.dcntrlized.org';
|
||||
export const QR_SCHEME = 'dcntrlized-alarm';
|
||||
export const QR_PATH_PREFIX = '/a/';
|
||||
|
||||
export interface QrPayload {
|
||||
alertId: string;
|
||||
subscribeToken: string;
|
||||
/** Where the payload came from — drives copy in the scanner error states. */
|
||||
source: 'url' | 'scheme' | 'relative' | 'raw';
|
||||
}
|
||||
|
||||
export type QrParseError =
|
||||
| 'empty'
|
||||
| 'not-an-alarm-code'
|
||||
| 'missing-alert-id'
|
||||
| 'missing-token';
|
||||
|
||||
export type QrParseResult =
|
||||
| { ok: true; payload: QrPayload }
|
||||
| { ok: false; error: QrParseError };
|
||||
|
||||
const OK = (payload: QrPayload): QrParseResult => ({ ok: true, payload });
|
||||
const FAIL = (error: QrParseError): QrParseResult => ({ ok: false, error });
|
||||
|
||||
/** Canonical payload an owner renders into a QR code. */
|
||||
export function buildQrPayload(alertId: string, subscribeToken: string): string {
|
||||
return `https://${QR_HOST}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`;
|
||||
}
|
||||
|
||||
/** Custom-scheme form, used by deep links and by the "share" affordance. */
|
||||
export function buildQrSchemePayload(alertId: string, subscribeToken: string): string {
|
||||
return `${QR_SCHEME}://a/${encodeURIComponent(alertId)}#${subscribeToken}`;
|
||||
}
|
||||
|
||||
function splitFragment(value: string): [string, string] {
|
||||
const i = value.indexOf('#');
|
||||
return i === -1 ? [value, ''] : [value.slice(0, i), value.slice(i + 1)];
|
||||
}
|
||||
|
||||
function stripQuery(value: string): string {
|
||||
const i = value.indexOf('?');
|
||||
return i === -1 ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the alert id from a path. `/a/{id}` is the contract path; the id is
|
||||
* whatever follows the `a/` prefix, so a trailing slash yields "" (missing)
|
||||
* rather than the literal "a".
|
||||
*/
|
||||
function alertIdFromPath(path: string): string {
|
||||
const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!trimmed) return '';
|
||||
const segments = trimmed.split('/').filter(Boolean);
|
||||
if (segments[0] === 'a') {
|
||||
return segments.length > 1 ? segments[1] : '';
|
||||
}
|
||||
return segments[segments.length - 1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a scanned / pasted QR payload. Never throws.
|
||||
*/
|
||||
export function parseQrPayload(raw: string): QrParseResult {
|
||||
const trimmed = (raw ?? '').trim();
|
||||
if (!trimmed) return FAIL('empty');
|
||||
|
||||
const [head, fragment] = splitFragment(trimmed);
|
||||
const withoutQuery = stripQuery(head);
|
||||
const token = decodeURIComponent(fragment.trim());
|
||||
|
||||
const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(withoutQuery);
|
||||
if (schemeMatch) {
|
||||
const scheme = schemeMatch[1].toLowerCase();
|
||||
if (scheme !== QR_SCHEME && scheme !== 'http' && scheme !== 'https') {
|
||||
return FAIL('not-an-alarm-code');
|
||||
}
|
||||
const rest = withoutQuery.slice(schemeMatch[0].length);
|
||||
// For http(s) the first segment is the authority (`host[:port]`); for the
|
||||
// custom scheme `rest` is already a path.
|
||||
let path: string;
|
||||
if (scheme === 'http' || scheme === 'https') {
|
||||
const slash = rest.indexOf('/');
|
||||
const authority = (slash === -1 ? rest : rest.slice(0, slash)).split(':')[0].toLowerCase();
|
||||
const hostOk =
|
||||
authority === QR_HOST ||
|
||||
authority.endsWith(`.${QR_HOST}`) ||
|
||||
authority === 'localhost' ||
|
||||
authority === '127.0.0.1' ||
|
||||
/^\d{1,3}(\.\d{1,3}){3}$/.test(authority);
|
||||
if (!hostOk) return FAIL('not-an-alarm-code');
|
||||
path = slash === -1 ? '' : rest.slice(slash);
|
||||
} else {
|
||||
path = rest.startsWith('/') ? rest : `/${rest}`;
|
||||
}
|
||||
const seg = alertIdFromPath(path);
|
||||
if (!seg) return FAIL('missing-alert-id');
|
||||
if (!token) return FAIL('missing-token');
|
||||
return OK({
|
||||
alertId: decodeURIComponent(seg),
|
||||
subscribeToken: token,
|
||||
source: scheme === QR_SCHEME ? 'scheme' : 'url',
|
||||
});
|
||||
}
|
||||
|
||||
// No scheme: accept `a/{id}#{token}` or `{id}#{token}`.
|
||||
const cleaned = withoutQuery.replace(/^\/+/, '');
|
||||
const seg = alertIdFromPath(cleaned);
|
||||
if (!seg) return FAIL('missing-alert-id');
|
||||
if (!token) return FAIL('missing-token');
|
||||
const looksRelative = cleaned.startsWith('a/') || cleaned.startsWith(`${QR_SCHEME}/`);
|
||||
return OK({
|
||||
alertId: decodeURIComponent(seg),
|
||||
subscribeToken: token,
|
||||
source: looksRelative ? 'relative' : 'raw',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual fallback: the user pastes either a full payload or just the token
|
||||
* fragment, while the alertId is already known (they are on the alert's join
|
||||
* screen or typed the id in).
|
||||
*/
|
||||
export function parseManualToken(input: string, alertId: string): QrParseResult {
|
||||
const trimmed = (input ?? '').trim();
|
||||
if (!trimmed) return FAIL('empty');
|
||||
const withId = parseQrPayload(trimmed);
|
||||
if (withId.ok) {
|
||||
return withId;
|
||||
}
|
||||
if (!alertId.trim()) {
|
||||
return withId.error === 'missing-token' ? FAIL('missing-alert-id') : withId;
|
||||
}
|
||||
// A bare token: no scheme, no '#', no '/'.
|
||||
if (/^[A-Za-z0-9._~-]+$/.test(trimmed)) {
|
||||
return OK({ alertId: alertId.trim(), subscribeToken: trimmed, source: 'raw' });
|
||||
}
|
||||
return withId;
|
||||
}
|
||||
|
||||
/** Human copy for a parse failure (scanner error states, DESIGN.md §7.5). */
|
||||
export function qrErrorMessage(error: QrParseError): string {
|
||||
switch (error) {
|
||||
case 'empty':
|
||||
return 'Nothing was scanned';
|
||||
case 'not-an-alarm-code':
|
||||
return 'Not a Dcntrlized alert code';
|
||||
case 'missing-alert-id':
|
||||
return 'This code has no alert id';
|
||||
case 'missing-token':
|
||||
return 'This code has no subscribe token';
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens render truncated to 12 chars + ellipsis (DESIGN.md §7.4). */
|
||||
export function truncateToken(token: string, max = 12): string {
|
||||
if (token.length <= max) return token;
|
||||
return `${token.slice(0, max)}…`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Human time helpers for the alarm surfaces.
|
||||
*
|
||||
* The takeover shows a count-up ("2s ago") rather than a server timestamp: on a
|
||||
* lock screen at arm's length an RFC3339 string is unreadable, and the number
|
||||
* that matters is how long the phone has been screaming. The absolute time is
|
||||
* still available as the accessibility label.
|
||||
*/
|
||||
|
||||
/** Parse a server RFC3339 string. Returns null when absent or unparseable. */
|
||||
export function parseServerTime(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isNaN(ms) ? null : new Date(ms);
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* Compact elapsed time. Deliberately coarse above a minute: the takeover updates
|
||||
* every second only while the alarm is fresh, and "13m ago" is more useful than
|
||||
* "13m 42s ago" on a wall of red.
|
||||
*/
|
||||
export function relativeTime(from: Date, now: number): string {
|
||||
const elapsed = now - from.getTime();
|
||||
// A clock skew between phone and server must not render "in 3 seconds".
|
||||
if (elapsed < SECOND) return 'just now';
|
||||
if (elapsed < MINUTE) return `${Math.floor(elapsed / SECOND)}s ago`;
|
||||
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
|
||||
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
|
||||
return `${Math.floor(elapsed / DAY)}d ago`;
|
||||
}
|
||||
|
||||
/** Convenience: relative time for a server string, or null when unparseable. */
|
||||
export function relativeSince(value: string | null | undefined, now: number): string | null {
|
||||
const date = parseServerTime(value);
|
||||
return date ? relativeTime(date, now) : null;
|
||||
}
|
||||
|
||||
/** Local wall-clock time for the accessibility label of a relative stamp. */
|
||||
export function absoluteLocal(date: Date): string {
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
Reference in New Issue
Block a user